summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx54
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx53
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx52
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx69
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx60
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx52
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx12
-rw-r--r--app/[lng]/engineering/(engineering)/document-list-only/layout.tsx17
-rw-r--r--app/[lng]/engineering/(engineering)/document-list-only/page.tsx98
-rw-r--r--app/[lng]/engineering/(engineering)/document-list-ship/page.tsx144
-rw-r--r--app/[lng]/engineering/(engineering)/faq/manage/actions.ts48
-rw-r--r--app/[lng]/engineering/(engineering)/faq/manage/page.tsx38
-rw-r--r--app/[lng]/engineering/(engineering)/faq/page.tsx62
-rw-r--r--app/[lng]/engineering/(engineering)/form-list/page.tsx75
-rw-r--r--app/[lng]/engineering/(engineering)/items/page.tsx68
-rw-r--r--app/[lng]/engineering/(engineering)/layout.tsx18
-rw-r--r--app/[lng]/engineering/(engineering)/projects/page.tsx75
-rw-r--r--app/[lng]/engineering/(engineering)/report/page.tsx105
-rw-r--r--app/[lng]/engineering/(engineering)/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/tasks/page.tsx63
-rw-r--r--app/[lng]/engineering/(engineering)/tbe/page.tsx113
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx79
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-data/layout.tsx67
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-data/page.tsx28
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx43
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx65
-rw-r--r--app/[lng]/engineering/page.tsx21
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx (renamed from app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx)124
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx87
-rw-r--r--app/[lng]/partners/(partners)/cbe/page.tsx89
-rw-r--r--app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx174
-rw-r--r--app/[lng]/partners/(partners)/rfq-answer/page.tsx213
-rw-r--r--app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx81
-rw-r--r--app/[lng]/partners/(partners)/rfq-ship/page.tsx174
-rw-r--r--app/[lng]/partners/(partners)/rfq/page.tsx136
-rw-r--r--app/[lng]/partners/(partners)/tbe/page.tsx88
-rw-r--r--app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx0
-rw-r--r--app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx52
-rw-r--r--app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx87
-rw-r--r--app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx53
-rw-r--r--app/[lng]/procurement/(procurement)/b-rfq/page.tsx79
-rw-r--r--app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/basic-contract/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/bqtbe/page.tsx72
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx90
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx57
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx86
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx90
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary/page.tsx86
-rw-r--r--app/[lng]/procurement/(procurement)/dashboard/page.tsx17
-rw-r--r--app/[lng]/procurement/(procurement)/equip-class/page.tsx75
-rw-r--r--app/[lng]/procurement/(procurement)/esg-check-list/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx81
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx22
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-input/page.tsx135
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx118
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation/page.tsx181
-rw-r--r--app/[lng]/procurement/(procurement)/faq/manage/actions.ts48
-rw-r--r--app/[lng]/procurement/(procurement)/faq/manage/page.tsx38
-rw-r--r--app/[lng]/procurement/(procurement)/faq/page.tsx62
-rw-r--r--app/[lng]/procurement/(procurement)/incoterms/page.tsx53
-rw-r--r--app/[lng]/procurement/(procurement)/items-tech/layout.tsx38
-rw-r--r--app/[lng]/procurement/(procurement)/items-tech/page.tsx67
-rw-r--r--app/[lng]/procurement/(procurement)/items/page.tsx68
-rw-r--r--app/[lng]/procurement/(procurement)/layout.tsx18
-rw-r--r--app/[lng]/procurement/(procurement)/menu-list/page.tsx70
-rw-r--r--app/[lng]/procurement/(procurement)/payment-conditions/page.tsx53
-rw-r--r--app/[lng]/procurement/(procurement)/po-rfq/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/po/page.tsx65
-rw-r--r--app/[lng]/procurement/(procurement)/poa/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx68
-rw-r--r--app/[lng]/procurement/(procurement)/pq-criteria/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx206
-rw-r--r--app/[lng]/procurement/(procurement)/pq_new/page.tsx99
-rw-r--r--app/[lng]/procurement/(procurement)/project-gtc/page.tsx63
-rw-r--r--app/[lng]/procurement/(procurement)/project-vendors/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/projects/page.tsx75
-rw-r--r--app/[lng]/procurement/(procurement)/report/page.tsx105
-rw-r--r--app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx89
-rw-r--r--app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq/page.tsx80
-rw-r--r--app/[lng]/procurement/(procurement)/settings/layout.tsx68
-rw-r--r--app/[lng]/procurement/(procurement)/settings/page.tsx18
-rw-r--r--app/[lng]/procurement/(procurement)/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/procurement/(procurement)/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/procurement/(procurement)/system/layout.tsx80
-rw-r--r--app/[lng]/procurement/(procurement)/system/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/system/password-policy/page.tsx63
-rw-r--r--app/[lng]/procurement/(procurement)/system/permissions/page.tsx17
-rw-r--r--app/[lng]/procurement/(procurement)/system/roles/page.tsx68
-rw-r--r--app/[lng]/procurement/(procurement)/tbe/page.tsx113
-rw-r--r--app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx65
-rw-r--r--app/[lng]/procurement/(procurement)/vendor-type/page.tsx70
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx94
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/vendors/page.tsx78
-rw-r--r--app/[lng]/procurement/page.tsx21
-rw-r--r--app/[lng]/sales/(sales)/bid-projects/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/bqtbe/page.tsx72
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx90
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx57
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/page.tsx86
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx90
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/budgetary/page.tsx86
-rw-r--r--app/[lng]/sales/(sales)/dashboard/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/esg-check-list/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/evaluation-check-list/page.tsx81
-rw-r--r--app/[lng]/sales/(sales)/evaluation-target-list/page.tsx115
-rw-r--r--app/[lng]/sales/(sales)/evaluation/page.tsx181
-rw-r--r--app/[lng]/sales/(sales)/faq/manage/actions.ts48
-rw-r--r--app/[lng]/sales/(sales)/faq/manage/page.tsx38
-rw-r--r--app/[lng]/sales/(sales)/faq/page.tsx62
-rw-r--r--app/[lng]/sales/(sales)/items-tech/layout.tsx38
-rw-r--r--app/[lng]/sales/(sales)/items-tech/page.tsx67
-rw-r--r--app/[lng]/sales/(sales)/items/page.tsx68
-rw-r--r--app/[lng]/sales/(sales)/layout.tsx18
-rw-r--r--app/[lng]/sales/(sales)/project-gtc/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/project-vendors/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/projects/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/report/page.tsx105
-rw-r--r--app/[lng]/sales/(sales)/settings/layout.tsx68
-rw-r--r--app/[lng]/sales/(sales)/settings/page.tsx18
-rw-r--r--app/[lng]/sales/(sales)/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/sales/(sales)/system/layout.tsx80
-rw-r--r--app/[lng]/sales/(sales)/system/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/system/password-policy/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/system/permissions/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/system/roles/page.tsx68
-rw-r--r--app/[lng]/sales/(sales)/tbe/page.tsx113
-rw-r--r--app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/tech-project-avl/page.tsx88
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx82
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx54
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/page.tsx60
-rw-r--r--app/[lng]/sales/(sales)/vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/sales/page.tsx21
-rw-r--r--app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts145
-rw-r--r--app/api/rfq-attachments/download/route.ts474
-rw-r--r--app/api/tbe-download/route.ts417
-rw-r--r--app/api/vendor-responses/update-comment/route.ts62
-rw-r--r--app/api/vendor-responses/update/route.ts118
-rw-r--r--app/api/vendor-responses/upload/route.ts105
-rw-r--r--app/api/vendor-responses/waive/route.ts69
-rw-r--r--components/ProjectSelector.tsx9
-rw-r--r--components/bidding/ProjectSelectorBid.tsx9
-rw-r--r--components/layout/Header.tsx57
-rw-r--r--config/menuConfig.ts18
-rw-r--r--lib/b-rfq/attachment/add-attachment-dialog.tsx355
-rw-r--r--lib/b-rfq/attachment/add-revision-dialog.tsx336
-rw-r--r--lib/b-rfq/attachment/attachment-columns.tsx286
-rw-r--r--lib/b-rfq/attachment/attachment-table.tsx190
-rw-r--r--lib/b-rfq/attachment/attachment-toolbar-action.tsx60
-rw-r--r--lib/b-rfq/attachment/confirm-documents-dialog.tsx141
-rw-r--r--lib/b-rfq/attachment/delete-attachment-dialog.tsx182
-rw-r--r--lib/b-rfq/attachment/request-revision-dialog.tsx205
-rw-r--r--lib/b-rfq/attachment/revision-dialog.tsx196
-rw-r--r--lib/b-rfq/attachment/tbe-request-dialog.tsx200
-rw-r--r--lib/b-rfq/attachment/vendor-responses-panel.tsx386
-rw-r--r--lib/b-rfq/final/final-rfq-detail-columns.tsx589
-rw-r--r--lib/b-rfq/final/final-rfq-detail-table.tsx297
-rw-r--r--lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx201
-rw-r--r--lib/b-rfq/final/update-final-rfq-sheet.tsx70
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx584
-rw-r--r--lib/b-rfq/initial/delete-initial-rfq-dialog.tsx149
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx446
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx267
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx287
-rw-r--r--lib/b-rfq/initial/short-list-confirm-dialog.tsx269
-rw-r--r--lib/b-rfq/initial/update-initial-rfq-sheet.tsx496
-rw-r--r--lib/b-rfq/repository.ts0
-rw-r--r--lib/b-rfq/service.ts2976
-rw-r--r--lib/b-rfq/summary-table/add-new-rfq-dialog.tsx523
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-columns.tsx499
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx617
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx68
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-table.tsx285
-rw-r--r--lib/b-rfq/validations.ts447
-rw-r--r--lib/b-rfq/vendor-response/comment-edit-dialog.tsx187
-rw-r--r--lib/b-rfq/vendor-response/response-detail-columns.tsx653
-rw-r--r--lib/b-rfq/vendor-response/response-detail-sheet.tsx358
-rw-r--r--lib/b-rfq/vendor-response/response-detail-table.tsx161
-rw-r--r--lib/b-rfq/vendor-response/upload-response-dialog.tsx325
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx351
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table.tsx152
-rw-r--r--lib/b-rfq/vendor-response/waive-response-dialog.tsx210
-rw-r--r--lib/cbe/table/cbe-table-columns.tsx241
-rw-r--r--lib/cbe/table/cbe-table-toolbar-actions.tsx72
-rw-r--r--lib/cbe/table/cbe-table.tsx192
-rw-r--r--lib/cbe/table/comments-sheet.tsx345
-rw-r--r--lib/cbe/table/invite-vendors-dialog.tsx428
-rw-r--r--lib/legal-review/service.ts738
-rw-r--r--lib/legal-review/status/create-legal-work-dialog.tsx506
-rw-r--r--lib/legal-review/status/delete-legal-works-dialog.tsx152
-rw-r--r--lib/legal-review/status/legal-table copy.tsx583
-rw-r--r--lib/legal-review/status/legal-table.tsx546
-rw-r--r--lib/legal-review/status/legal-work-detail-dialog.tsx409
-rw-r--r--lib/legal-review/status/legal-work-filter-sheet.tsx897
-rw-r--r--lib/legal-review/status/legal-works-columns.tsx222
-rw-r--r--lib/legal-review/status/legal-works-toolbar-actions.tsx286
-rw-r--r--lib/legal-review/status/request-review-dialog.tsx983
-rw-r--r--lib/legal-review/status/update-legal-work-dialog.tsx385
-rw-r--r--lib/legal-review/validations.ts40
-rw-r--r--lib/procurement-rfqs/repository.ts50
-rw-r--r--lib/procurement-rfqs/services.ts2050
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
-rw-r--r--lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx393
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx521
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--lib/procurement-rfqs/table/pr-item-dialog.tsx258
-rw-r--r--lib/procurement-rfqs/table/rfq-filter-sheet.tsx686
-rw-r--r--lib/procurement-rfqs/table/rfq-table-column.tsx373
-rw-r--r--lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx279
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx412
-rw-r--r--lib/procurement-rfqs/validations.ts61
-rw-r--r--lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx955
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx333
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx152
-rw-r--r--lib/projects/service.ts37
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx245
-rw-r--r--lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx178
-rw-r--r--lib/rfqs/cbe-table/comments-sheet.tsx328
-rw-r--r--lib/rfqs/cbe-table/invite-vendors-dialog.tsx423
-rw-r--r--lib/rfqs/cbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/repository.ts232
-rw-r--r--lib/rfqs/service.ts3951
-rw-r--r--lib/rfqs/table/ItemsDialog.tsx752
-rw-r--r--lib/rfqs/table/ParentRfqSelector.tsx307
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx468
-rw-r--r--lib/rfqs/table/attachment-rfq-sheet.tsx429
-rw-r--r--lib/rfqs/table/delete-rfqs-dialog.tsx149
-rw-r--r--lib/rfqs/table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/table/feature-flags.tsx96
-rw-r--r--lib/rfqs/table/rfqs-table-columns.tsx315
-rw-r--r--lib/rfqs/table/rfqs-table-floating-bar.tsx338
-rw-r--r--lib/rfqs/table/rfqs-table-toolbar-actions.tsx55
-rw-r--r--lib/rfqs/table/rfqs-table.tsx263
-rw-r--r--lib/rfqs/table/update-rfq-sheet.tsx406
-rw-r--r--lib/rfqs/tbe-table/comments-sheet.tsx325
-rw-r--r--lib/rfqs/tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/tbe-table/file-dialog.tsx141
-rw-r--r--lib/rfqs/tbe-table/invite-vendors-dialog.tsx220
-rw-r--r--lib/rfqs/tbe-table/tbe-result-dialog.tsx208
-rw-r--r--lib/rfqs/tbe-table/tbe-table-columns.tsx373
-rw-r--r--lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs/tbe-table/tbe-table.tsx220
-rw-r--r--lib/rfqs/tbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx70
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx89
-rw-r--r--lib/rfqs/validations.ts297
-rw-r--r--lib/rfqs/vendor-table/add-vendor-dialog.tsx37
-rw-r--r--lib/rfqs/vendor-table/comments-sheet.tsx318
-rw-r--r--lib/rfqs/vendor-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/vendor-table/invite-vendors-dialog.tsx177
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx154
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx142
-rw-r--r--lib/rfqs/vendor-table/vendors-table-columns.tsx276
-rw-r--r--lib/rfqs/vendor-table/vendors-table-floating-bar.tsx137
-rw-r--r--lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx84
-rw-r--r--lib/rfqs/vendor-table/vendors-table.tsx208
-rw-r--r--lib/tbe/service.ts0
-rw-r--r--lib/tbe/table/comments-sheet.tsx345
-rw-r--r--lib/tbe/table/feature-flags-provider.tsx108
-rw-r--r--lib/tbe/table/file-dialog.tsx141
-rw-r--r--lib/tbe/table/invite-vendors-dialog.tsx209
-rw-r--r--lib/tbe/table/tbe-result-dialog.tsx208
-rw-r--r--lib/tbe/table/tbe-table-columns.tsx344
-rw-r--r--lib/tbe/table/tbe-table-toolbar-actions.tsx72
-rw-r--r--lib/tbe/table/tbe-table.tsx243
-rw-r--r--lib/tbe/table/vendor-contact-dialog.tsx71
-rw-r--r--lib/vendor-rfq-response/service.ts464
-rw-r--r--lib/vendor-rfq-response/types.ts76
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx365
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx272
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx323
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx427
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx89
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx62
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx125
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx106
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx320
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx435
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx40
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx280
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx346
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx350
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx188
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx355
-rw-r--r--lib/vendors/table/request-project-pq-dialog.tsx9
-rw-r--r--middleware.ts24
324 files changed, 175 insertions, 63289 deletions
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx
deleted file mode 100644
index 5aebf15d..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import * as React from "react";
-import { type SearchParams } from "@/types/table";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { getCodeGroups } from "@/lib/docu-list-rule/code-groups/service";
-import { CodeGroupsTable } from "@/lib/docu-list-rule/code-groups/table/code-groups-table";
-import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
-import { InformationButton } from "@/components/information/information-button";
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>;
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCodeGroupsCache.parse(searchParams);
-
- const promises = Promise.all([
- getCodeGroups({
- ...search,
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Code Group 정의</h2>
- <InformationButton pagePath="evcp/docu-list-rule/code-groups" />
- </div>
- {/* <p className="text-muted-foreground">
- 문서 번호에 사용될 수 있는 다양한 코드 그룹의 정의를 관리하는 페이지입니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={7}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["8rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem"]}
- shrinkZero
- />
- }
- >
- <CodeGroupsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx
deleted file mode 100644
index cf0bf02e..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { getComboBoxCodeGroups } from "@/lib/docu-list-rule/combo-box-settings/service";
-import { ComboBoxSettingsTable } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table";
-import { InformationButton } from "@/components/information/information-button";
-import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
-
-interface IndexPageProps {
- searchParams: Promise<any>;
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams;
-
- const promises = Promise.all([
- getComboBoxCodeGroups(
- searchParamsCodeGroupsCache.parse(searchParams)
- ),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Combo Box 설정</h2>
- <InformationButton pagePath="evcp/docu-list-rule/combo-box-settings" />
- </div>
- {/* <p className="text-muted-foreground">
- Combo Box 옵션을 관리하는 페이지입니다.
- 각 Code Group별로 Combo Box에 표시될 옵션들을 설정할 수 있습니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["8rem", "12rem", "10rem", "8rem", "12rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ComboBoxSettingsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx
deleted file mode 100644
index 5c2c600e..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as React from "react";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service";
-import { DocumentClassTable } from "@/lib/docu-list-rule/document-class/table/document-class-table";
-import { InformationButton } from "@/components/information/information-button";
-import { searchParamsDocumentClassCache } from "@/lib/docu-list-rule/document-class/validation";
-
-interface IndexPageProps {
- searchParams: Promise<any>;
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams;
-
- const promises = Promise.all([
- getDocumentClassCodeGroups(
- searchParamsDocumentClassCache.parse(searchParams)
- ),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Document Class 관리</h2>
- <InformationButton pagePath="evcp/docu-list-rule/document-class" />
- </div>
- {/* <p className="text-muted-foreground">
- Document Class를 관리합니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={1}
- filterableColumnCount={1}
- cellWidths={["10rem", "20rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <DocumentClassTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx
deleted file mode 100644
index 25023e4b..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-
-export const metadata: Metadata = {
- title: "Document Numbering Rule",
-}
-
-
-
-export default async function DocumentNumberingLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string }
-}) {
- const resolvedParams = await params
- const lng = resolvedParams.lng
-
- const sidebarNavItems = [
- {
- title: "Document Class 관리",
- href: `/${lng}/engineering/docu-list-rule/document-class`,
- },
- {
- title: "Code Group 정의",
- href: `/${lng}/engineering/docu-list-rule/code-groups`,
- },
- {
- title: "Combo Box 설정",
- href: `/${lng}/engineering/docu-list-rule/combo-box-settings`,
- },
- {
- title: "Number Type 관리",
- href: `/${lng}/engineering/docu-list-rule/number-types`,
- },
- {
- title: "Number Type별 설정",
- href: `/${lng}/engineering/docu-list-rule/number-type-configs`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2>
- <p className="text-muted-foreground">
- 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링
- </p>
- </div>
-
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 ">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx
deleted file mode 100644
index 4195ba24..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as React from "react";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { NumberTypeConfigsTable } from "@/lib/docu-list-rule/number-type-configs/table/number-type-configs-table";
-import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
-import { InformationButton } from "@/components/information/information-button";
-
-interface IndexPageProps {
- searchParams: Promise<any>;
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams;
-
- const promises = Promise.all([
- getNumberTypes({
- page: 1,
- perPage: 1000, // 모든 Number Type을 가져오기 위해 큰 값 설정
- search: "",
- sort: [{ id: "id", desc: false }], // DB 등록 순서대로 정렬
- filters: [],
- joinOperator: "and",
- flags: ["advancedTable"],
- numberTypeId: "",
- description: "",
- isActive: ""
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Number Type별 설정</h2>
- <InformationButton pagePath="evcp/docu-list-rule/number-type-configs" />
- </div>
- {/* <p className="text-muted-foreground">
- 각 문서 번호 유형별로 어떤 코드 그룹들을 어떤 순서로 사용할지 설정하는 페이지입니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "12rem", "12rem", "12rem"]}
- shrinkZero
- />
- }
- >
- <NumberTypeConfigsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx
deleted file mode 100644
index 6fa010c7..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as React from "react";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { NumberTypesTable } from "@/lib/docu-list-rule/number-types/table/number-types-table";
-import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
-import { InformationButton } from "@/components/information/information-button";
-import { searchParamsNumberTypesCache } from "@/lib/docu-list-rule/number-types/validation";
-
-interface IndexPageProps {
- searchParams: Promise<any>;
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams;
-
- const promises = Promise.all([
- getNumberTypes(
- searchParamsNumberTypesCache.parse(searchParams)
- ),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Number Type 관리</h2>
- <InformationButton pagePath="evcp/docu-list-rule/number-types" />
- </div>
- {/* <p className="text-muted-foreground">
- 문서 번호 유형을 추가, 수정, 삭제할 수 있는 페이지입니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={1}
- filterableColumnCount={0}
- cellWidths={["10rem", "20rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <NumberTypesTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx
deleted file mode 100644
index b8d3559f..00000000
--- a/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { redirect } from "next/navigation"
-
-
-export default async function DocumentNumberingPage({
- params,
-}: {
- params: Promise<{ lng: string }>
-}) {
- const resolvedParams = await params;
- // Code Group 페이지로 리다이렉트
- redirect(`/${resolvedParams.lng}/engineering/docu-list-rule/document-class`)
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx b/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx
deleted file mode 100644
index 17e78c0a..00000000
--- a/app/[lng]/engineering/(engineering)/document-list-only/layout.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Shell } from "@/components/shell"
-import VendorDocumentListClientEvcp from "@/components/document-lists/vendor-doc-list-client-evcp"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default async function EvcpDocuments({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
- <Shell className="gap-2">
- <VendorDocumentListClientEvcp>
- {children}
- </VendorDocumentListClientEvcp>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/document-list-only/page.tsx b/app/[lng]/engineering/(engineering)/document-list-only/page.tsx
deleted file mode 100644
index 5b49a6ef..00000000
--- a/app/[lng]/engineering/(engineering)/document-list-only/page.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-// evcp/document-list-only/page.tsx - 전체 계약 대상 문서 목록
-import * as React from "react"
-import { Suspense } from "react"
-import { Skeleton } from "@/components/ui/skeleton"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { DocumentStagesTable } from "@/lib/vendor-document-list/plant/document-stages-table"
-import { documentStageSearchParamsCache } from "@/lib/vendor-document-list/plant/document-stage-validations"
-import { getDocumentStagesOnly } from "@/lib/vendor-document-list/plant/document-stages-service"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// 문서 테이블 래퍼 컴포넌트 (전체 계약용)
-async function DocumentTableWrapper({
- searchParams
-}: {
- searchParams: SearchParams
-}) {
- const search = documentStageSearchParamsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 필터 타입 변환
- const convertedFilters = validFilters.map(filter => ({
- id: (filter.id || filter.rowId) as string,
- value: filter.value,
- operator: (filter.operator === 'iLike' ? 'ilike' :
- filter.operator === 'notILike' ? 'notin' :
- filter.operator === 'isEmpty' ? 'eq' :
- filter.operator === 'isNotEmpty' ? 'ne' :
- filter.operator === 'isBetween' ? 'eq' :
- filter.operator === 'isRelativeToToday' ? 'eq' :
- filter.operator || 'eq') as 'eq' | 'in' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'like' | 'ilike' | 'notin'
- }))
-
- // evcp: 전체 계약 대상으로 문서 조회
- const documentsPromise = getDocumentStagesOnly({
- ...search,
- filters: convertedFilters,
- }, -1) // 세션에서 자동으로 도메인 감지
-
- return (
- <DocumentStagesTable
- promises={Promise.all([documentsPromise])}
- contractId={-1} // 전체 계약을 의미
- projectType="plant" // 기본값으로 plant 사용
- />
- )
-}
-
-function TableLoadingSkeleton() {
- return (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Skeleton className="h-6 w-32" />
- <div className="flex items-center gap-2">
- <Skeleton className="h-8 w-20" />
- <Skeleton className="h-8 w-24" />
- </div>
- </div>
- <div className="rounded-md border">
- <div className="p-4">
- <div className="space-y-3">
- {Array.from({ length: 5 }).map((_, i) => (
- <div key={i} className="flex items-center space-x-4">
- <Skeleton className="h-4 w-4" />
- <Skeleton className="h-4 w-24" />
- <Skeleton className="h-4 w-48" />
- <Skeleton className="h-4 w-20" />
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-12" />
- </div>
- ))}
- </div>
- </div>
- </div>
- </div>
- )
-}
-
-// 메인 페이지 컴포넌트
-export default async function DocumentStagesManagementPage({
- searchParams
-}: IndexPageProps) {
- const resolvedSearchParams = await searchParams
-
- return (
- <div className="mx-auto">
- {/* 문서 테이블 */}
- <Suspense fallback={<TableLoadingSkeleton />}>
- <DocumentTableWrapper
- searchParams={resolvedSearchParams}
- />
- </Suspense>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx b/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx
deleted file mode 100644
index e3915419..00000000
--- a/app/[lng]/engineering/(engineering)/document-list-ship/page.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-// page.tsx (간단한 Promise 생성과 로그인 처리)
-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 { searchParamsShipDocuCache } from "@/lib/vendor-document-list/validations"
-import { getServerSession } from "next-auth"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import Link from "next/link"
-import { Button } from "@/components/ui/button"
-import { LogIn } from "lucide-react"
-import { getUserVendorDocumentStats, getUserVendorDocumentStatsAll, getUserVendorDocuments, getUserVendorDocumentsAll } from "@/lib/vendor-document-list/enhanced-document-service"
-import { UserVendorDocumentDisplay } from "@/components/ship-vendor-document/user-vendor-document-table-container"
-import { InformationButton } from "@/components/information/information-button"
-import { UserVendorALLDocumentDisplay } from "@/components/ship-vendor-document-all/user-vendor-document-table-container"
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsShipDocuCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // Get session
- const session = await getServerSession(authOptions)
-
- // Check if user is logged in
- if (!session || !session.user) {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 문서 관리
- </h2>
-
- </div>
- {/* <p className="text-muted-foreground">
- 소속 회사의 모든 도서/도면을 확인하고 관리합니다.
- </p> */}
- </div>
- </div>
-
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 문서를 확인하려면 먼저 로그인하세요.
- </p>
- <Button size="lg" asChild>
- <Link href="/partners">
- <LogIn className="mr-2 h-4 w-4" />
- 로그인하기
- </Link>
- </Button>
- </div>
- </div>
- </Shell>
- )
- }
-
- // User is logged in, get user ID
- const requesterId = session.user.id ? Number(session.user.id) : null
-
- if (!requesterId) {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- Document Management
- </h2>
- </div>
- </div>
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">계정 오류</h3>
- <p className="mb-6 text-muted-foreground">
- 사용자 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요.
- </p>
- </div>
- </div>
- </Shell>
- )
- }
-
- // 검색 파라미터 정리
- const searchInput = {
- ...search,
- filters: validFilters,
- }
-
- // Promise 생성 (모든 데이터를 페이지에서 처리)
- const documentsPromise = getUserVendorDocumentsAll(requesterId, searchInput)
- const statsPromise = getUserVendorDocumentStatsAll(requesterId)
-
- // Promise.all로 감싸서 전달
- const allPromises = Promise.all([documentsPromise, statsPromise])
- const statsResult = await documentsPromise
-
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 조선 Document Management
- </h2>
- <InformationButton pagePath="evcp/document-list-ship" />
- </div>
- <p className="text-muted-foreground">
-
- </p>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker can go here */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={3}
- cellWidths={["10rem", "30rem", "15rem", "15rem", "15rem", "15rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <UserVendorALLDocumentDisplay
- allPromises={allPromises}
- />
- </React.Suspense>
- </Shell>
- )
-}
-
diff --git a/app/[lng]/engineering/(engineering)/faq/manage/actions.ts b/app/[lng]/engineering/(engineering)/faq/manage/actions.ts
deleted file mode 100644
index bc443a8a..00000000
--- a/app/[lng]/engineering/(engineering)/faq/manage/actions.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/faq/manage/page.tsx b/app/[lng]/engineering/(engineering)/faq/manage/page.tsx
deleted file mode 100644
index 011bbfa4..00000000
--- a/app/[lng]/engineering/(engineering)/faq/manage/page.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/faq/page.tsx b/app/[lng]/engineering/(engineering)/faq/page.tsx
deleted file mode 100644
index 9b62b7e4..00000000
--- a/app/[lng]/engineering/(engineering)/faq/page.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">Frequently Asked Questions</h2>
- <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p>
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- Manage FAQ
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/form-list/page.tsx b/app/[lng]/engineering/(engineering)/form-list/page.tsx
deleted file mode 100644
index a2c6fbb9..00000000
--- a/app/[lng]/engineering/(engineering)/form-list/page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-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/form-list/validation"
-import { ItemsTable } from "@/lib/items/table/items-table"
-import { getFormLists } from "@/lib/form-list/service"
-import { FormListsTable } from "@/lib/form-list/table/formLists-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getFormLists({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 레지스터 목록 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 데이터 입력을 위한 레지스터 목록 리스트입니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <FormListsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/items/page.tsx b/app/[lng]/engineering/(engineering)/items/page.tsx
deleted file mode 100644
index f8d9a5b1..00000000
--- a/app/[lng]/engineering/(engineering)/items/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-// app/items/page.tsx (업데이트)
-import * as React from "react"
-import { type SearchParams } from "@/types/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/items/validations"
-import { getItems } from "@/lib/items/service"
-import { ItemsTable } from "@/lib/items/table/items-table"
-import { ViewModeToggle } from "@/components/data-table/view-mode-toggle"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- // pageSize 기반으로 모드 자동 결정
- const isInfiniteMode = search.perPage >= 1_000_000
-
- // 페이지네이션 모드일 때만 서버에서 데이터 가져오기
- // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드
- const promises = isInfiniteMode
- ? undefined
- : Promise.all([
- getItems(search), // searchParamsCache의 결과를 그대로 사용
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 패키지 넘버
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다.
- </p> */}
- </div>
- </div>
-
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker 등 추가 컴포넌트 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- {/* 통합된 ItemsTable 컴포넌트 사용 */}
- <ItemsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/layout.tsx b/app/[lng]/engineering/(engineering)/layout.tsx
deleted file mode 100644
index 82b53307..00000000
--- a/app/[lng]/engineering/(engineering)/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
-import { SiteFooter } from '@/components/layout/Footer';
-
-export default function EvcpLayout({ children }: { children: ReactNode }) {
- return (
- <div className="relative flex min-h-svh flex-col bg-background">
- {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */}
- <Header />
- <main className="flex flex-1 flex-col">
- <div className='container-wrapper'>
- {children}
- </div>
- </main>
- <SiteFooter/>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/projects/page.tsx b/app/[lng]/engineering/(engineering)/projects/page.tsx
deleted file mode 100644
index 199b175b..00000000
--- a/app/[lng]/engineering/(engineering)/projects/page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-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 { ItemsTable } from "@/lib/items/table/items-table"
-import { getProjectLists } from "@/lib/projects/service"
-import { ProjectsTable } from "@/lib/projects/table/projects-table"
-import { searchParamsProjectsCache } from "@/lib/projects/validation"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsProjectsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getProjectLists({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 리스트 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ProjectsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/report/page.tsx b/app/[lng]/engineering/(engineering)/report/page.tsx
deleted file mode 100644
index 64778ef1..00000000
--- a/app/[lng]/engineering/(engineering)/report/page.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import * as React from "react";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Shell } from "@/components/shell";
-import { ErrorBoundary } from "@/components/error-boundary";
-import { getDashboardData } from "@/lib/dashboard/service";
-import { DashboardClient } from "@/lib/dashboard/dashboard-client";
-
-// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생.
-// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리
-// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨.
-export const dynamic = 'force-dynamic'
-
-export default async function IndexPage() {
- // domain을 명시적으로 전달
- const domain = "engineering";
-
- try {
- // 서버에서 직접 데이터 fetch
- const dashboardData = await getDashboardData(domain);
-
- return (
- <Shell className="gap-2">
- <DashboardClient initialData={dashboardData} />
- </Shell>
- );
- } catch (error) {
- console.error("Dashboard data fetch error:", error);
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-center py-12">
- <div className="text-center space-y-2">
- <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p>
- <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p>
- </div>
- </div>
- </Shell>
- );
- }
-}
-
-function DashboardSkeleton() {
- return (
- <div className="space-y-6">
- {/* 헤더 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="space-y-2">
- <Skeleton className="h-8 w-48" />
- <Skeleton className="h-4 w-72" />
- </div>
- <Skeleton className="h-10 w-24" />
- </div>
-
- {/* 요약 카드 스켈레톤 */}
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
- {[...Array(4)].map((_, i) => (
- <div key={i} className="space-y-3 p-6 border rounded-lg">
- <div className="flex items-center justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-4" />
- </div>
- <Skeleton className="h-8 w-12" />
- <Skeleton className="h-3 w-20" />
- </div>
- ))}
- </div>
-
- {/* 차트 스켈레톤 */}
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
- {[...Array(2)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <div className="space-y-2">
- <Skeleton className="h-6 w-32" />
- <Skeleton className="h-4 w-48" />
- </div>
- <Skeleton className="h-[300px] w-full" />
- </div>
- ))}
- </div>
-
- {/* 탭 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-10 w-64" />
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
- {[...Array(6)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <Skeleton className="h-6 w-32" />
- <div className="space-y-3">
- <div className="flex justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-12" />
- </div>
- <div className="flex gap-2">
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- </div>
- <Skeleton className="h-2 w-full" />
- </div>
- </div>
- ))}
- </div>
- </div>
- </div>
- );
-}
diff --git a/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx b/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx
deleted file mode 100644
index 86ad2ec2..00000000
--- a/app/[lng]/engineering/(engineering)/tag-numbering/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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/tag-numbering/validation"
-import { getTagNumbering } from "@/lib/tag-numbering/service"
-import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getTagNumbering({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 태그 타입 목록 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TagNumberingTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/tasks/page.tsx b/app/[lng]/engineering/(engineering)/tasks/page.tsx
deleted file mode 100644
index 91b946fb..00000000
--- a/app/[lng]/engineering/(engineering)/tasks/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-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 { DateRangePicker } from "@/components/date-range-picker"
-import { Shell } from "@/components/shell"
-
-import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider"
-import { TasksTable } from "@/lib/tasks/table/tasks-table"
-import {
- getTaskPriorityCounts,
- getTasks,
- getTaskStatusCounts,
-} from "@/lib/tasks/service"
-import { searchParamsCache } from "@/lib/tasks/validations"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getTasks({
- ...search,
- filters: validFilters,
- }),
- getTaskStatusCounts(),
- getTaskPriorityCounts(),
- ])
-
- return (
- <Shell className="gap-2">
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- />
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TasksTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/tbe/page.tsx b/app/[lng]/engineering/(engineering)/tbe/page.tsx
deleted file mode 100644
index 211cf376..00000000
--- a/app/[lng]/engineering/(engineering)/tbe/page.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-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"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-
-interface IndexPageProps {
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
-}
-
-// 타입별 페이지 설명 구성 (Budgetary 제외)
-const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = {
- "purchase": {
- title: "Purchase RFQ Technical Bid Evaluation",
- description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE
- },
- "purchase-budgetary": {
- title: "Purchase Budgetary RFQ Technical Bid Evaluation",
- description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE_BUDGETARY
- }
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- // URL 쿼리 파라미터에서 타입 추출
- const searchParams = await props.searchParams
- // 기본값으로 'purchase' 사용
- const typeParam = searchParams?.type as string || 'purchase'
-
- // 유효한 타입인지 확인하고 기본값 설정
- const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase'
- const rfqType = typeConfig[validType].rfqType
-
- // SearchParams 파싱 (Zod)
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 현재 선택된 타입의 데이터 로드
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- })
- ])
-
- // 페이지 경로 생성 함수 - 단순화
- const getTabUrl = (type: string) => {
- return `/${lng}/evcp/tbe?type=${type}`;
- }
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- {/* 타입 선택 탭 (Budgetary 제외) */}
- <Tabs defaultValue={validType} value={validType} className="w-full">
- <TabsList className="grid grid-cols-2 w-full max-w-md">
- <TabsTrigger value="purchase" asChild>
- <a href={getTabUrl('purchase')}>Purchase</a>
- </TabsTrigger>
- <TabsTrigger value="purchase-budgetary" asChild>
- <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a>
- </TabsTrigger>
- </TabsList>
-
- <div className="mt-2">
- <p className="text-sm text-muted-foreground">
- {typeConfig[validType].description}
- </p>
- </div>
- </Tabs>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx b/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx
deleted file mode 100644
index e6f9ce82..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-check-list/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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 { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation"
-import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table"
-import { getGeneralEvaluations } from "@/lib/general-check-list/service"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = getGenralEvaluationsSchema.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getGeneralEvaluations({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가자료 문항 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <GeneralEvaluationsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx
deleted file mode 100644
index f69aa525..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import DynamicTable from "@/components/form-data/form-data-table";
-import { findContractItemId, getFormData, getFormId } from "@/lib/forms/services";
-
-interface IndexPageProps {
- params: {
- lng: string;
- packageId: string;
- formId: string;
- projectId: string;
- contractId: string;
-
-
- };
- searchParams?: {
- mode?: string;
- };
-}
-
-export default async function FormPage({ params, searchParams }: IndexPageProps) {
- // 1) 구조 분해 할당
- const resolvedParams = await params;
-
- // 2) searchParams도 await 필요
- const resolvedSearchParams = await searchParams;
-
- // 3) 구조 분해 할당
- const { lng, packageId, formId: formCode, projectId,contractId } = resolvedParams;
-
- // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용)
- const mode = resolvedSearchParams?.mode === "ENG" ? "ENG" : "IM"; // 기본값은 IM
-
- // 4) 변환
- let packageIdAsNumber = Number(packageId);
- const contractIdAsNumber = Number(contractId);
-
- // packageId가 0이면 contractId와 formCode로 실제 contractItemId 찾기
- if (packageIdAsNumber === 0 && contractIdAsNumber > 0) {
- console.log(`packageId가 0이므로 contractId ${contractIdAsNumber}와 formCode ${formCode}로 contractItemId 조회`);
-
- const foundContractItemId = await findContractItemId(contractIdAsNumber, formCode);
-
- if (foundContractItemId) {
- console.log(`contractItemId ${foundContractItemId}를 찾았습니다. 이 값을 사용합니다.`);
- packageIdAsNumber = foundContractItemId;
- } else {
- console.warn(`contractItemId를 찾을 수 없습니다. packageId는 계속 0으로 유지됩니다.`);
- }
- }
-
- // 5) DB 조회
- const { columns, data, editableFieldsMap } = await getFormData(formCode, packageIdAsNumber);
-
-
- // 6) formId 및 report temp file 조회
- const { formId } = await getFormId(String(packageIdAsNumber), formCode);
-
- // 7) 예외 처리
- if (!columns) {
- return (
- <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다. ENG 모드의 경우에는 SHI 관리자에게 폼 생성 요청을 하시기 바랍니다.</p>
- );
- }
-
- // 8) 렌더링
- return (
- <div className="space-y-6">
- <DynamicTable
- contractItemId={packageIdAsNumber}
- formCode={formCode}
- formId={formId}
- columnsJSON={columns}
- dataJSON={data}
- projectId={Number(projectId)}
- editableFieldsMap={editableFieldsMap} // 새로 추가
- mode={mode} // 모드 전달
- />
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx b/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx
deleted file mode 100644
index 7d00359c..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-data/layout.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-// app/vendor-data/layout.tsx
-import * as React from "react"
-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 { InformationButton } from "@/components/information/information-button"
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default async function VendorDataLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // evcp: 전체 계약 대상으로 프로젝트 데이터 가져오기
- const projects = await getVendorProjectsAndContracts()
-
- // 레이아웃 설정 쿠키 가져오기
- // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용
- const cookieStore = await cookies()
-
- // 이제 cookieStore.get() 메서드 사용 가능
- const layout = cookieStore.get("react-resizable-panels:layout:mail")
- const collapsed = cookieStore.get("react-resizable-panels:collapsed")
-
- const defaultLayout = layout ? JSON.parse(layout.value) : undefined
- const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 데이터 입력
- </h2>
- <InformationButton pagePath="partners/vendor-data" />
- </div>
- {/* <p className="text-muted-foreground">
- 각종 Data 입력할 수 있습니다
- </p> */}
- </div>
- </div>
- </div>
-
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden flex-col md:flex">
- {projects.length === 0 ? (
- <div className="p-4 text-center text-sm text-muted-foreground">
- No projects found for this vendor.
- </div>
- ) : (
- <VendorDataContainer
- projects={projects}
- defaultLayout={defaultLayout}
- defaultCollapsed={defaultCollapsed}
- navCollapsedSize={4}
- >
- {/* 페이지별 콘텐츠가 여기에 들어갑니다 */}
- {children}
- </VendorDataContainer>
- )}
- </div>
- </section>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/vendor-data/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/page.tsx
deleted file mode 100644
index ddc21a2b..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-data/page.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// evcp/vendor-data/page.tsx - 전체 계약 대상 협력업체 데이터
-import * as React from "react"
-import { Separator } from "@/components/ui/separator"
-
-export default async function IndexPage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">전체 계약 협력업체 데이터 대시보드</h3>
- <p className="text-sm text-muted-foreground">
- 모든 계약의 협력업체 데이터를 확인하고 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div className="grid gap-4">
- <div className="rounded-lg border p-4">
- <h4 className="text-sm font-medium">사용 방법</h4>
- <p className="text-sm text-muted-foreground mt-1">
- 1. 왼쪽 사이드바에서 계약을 선택하세요.<br />
- 2. 선택한 계약의 패키지 항목을 클릭하세요.<br />
- 3. 패키지의 태그 정보를 확인하고 관리할 수 있습니다.<br />
- 4. 폼 항목을 클릭하여 칼럼 정보를 확인하고 관리할 수 있습니다.
- </p>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx b/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx
deleted file mode 100644
index 7250732f..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-data/tag/[id]/page.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { TagsTable } from "@/lib/tags/table/tag-table"
-import { searchParamsCache } from "@/lib/tags/validations"
-import { getTags } from "@/lib/tags/service"
-
-interface IndexPageProps {
- params: {
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function TagPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTags({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <TagsTable promises={promises} selectedPackageId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx b/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx
deleted file mode 100644
index af9f3e11..00000000
--- a/app/[lng]/engineering/(engineering)/vendor-investigation/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-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 { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/investigation-table"
-import { getVendorsInvestigation } from "@/lib/vendor-investigation/service"
-import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsInvestigationCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getVendorsInvestigation({
- ...search,
- filters: validFilters,
- }),
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 실사 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다.
-
- </p> */}
- </div>
- </div>
- </div>
-
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorsInvestigationTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/page.tsx b/app/[lng]/engineering/page.tsx
deleted file mode 100644
index f9662cb7..00000000
--- a/app/[lng]/engineering/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Metadata } from "next"
-import { Suspense } from "react"
-import { LoginFormSkeleton } from "@/components/login/login-form-skeleton"
-import { LoginFormSHI } from "@/components/login/login-form-shi"
-
-export const metadata: Metadata = {
- title: "eVCP Portal",
- description: "",
-}
-
-export default function AuthenticationPage() {
-
-
- return (
- <>
- <Suspense fallback={<LoginFormSkeleton/>}>
- <LoginFormSHI />
- </Suspense>
- </>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx
index e3810b5b..2b907a75 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx
@@ -1,62 +1,62 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getProcurementItems } from "@/lib/procurement-items/service"
-import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table"
-import { searchParamsCache } from "@/lib/procurement-items/validations"
-import { InformationButton } from "@/components/information/information-button"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getProcurementItems({
- ...search,
- filters: validFilters,
- }),
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 1회성 품목 관리
- </h2>
- <InformationButton pagePath="evcp/procurement-items" />
- </div>
- <p className="text-muted-foreground">
- 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다.
- </p>
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "20rem", "8rem", "12rem", "6rem", "8rem", "10rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <ProcurementItemsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getProcurementItems } from "@/lib/procurement-items/service"
+import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table"
+import { searchParamsCache } from "@/lib/procurement-items/validations"
+import { InformationButton } from "@/components/information/information-button"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+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([
+ getProcurementItems({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 1회성 품목 관리
+ </h2>
+ <InformationButton pagePath="evcp/procurement-items" />
+ </div>
+ <p className="text-muted-foreground">
+ 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "20rem", "8rem", "12rem", "6rem", "8rem", "10rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProcurementItemsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx
deleted file mode 100644
index 44150492..00000000
--- a/app/[lng]/evcp/(evcp)/(procurement)/legal-review/page.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-// app/(routes)/legal-works/page.tsx 수정
-
-import * as React from "react";
-import { Metadata } from "next";
-import { type SearchParams } from "@/types/table";
-import { Shell } from "@/components/shell";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { InformationButton } from "@/components/information/information-button";
-import { Badge } from "@/components/ui/badge"; // ✅ Badge 추가
-import { SearchParamsCacheLegalWorks } from "@/lib/legal-review/validations";
-import { getLegalWorks } from "@/lib/legal-review/service";
-import { LegalWorksTable } from "@/lib/legal-review/status/legal-table";
-
-export const dynamic = "force-dynamic";
-export const revalidate = 0;
-
-export const metadata: Metadata = {
- title: "법무검토 관리",
- description: "법무 검토 요청 및 답변을 관리합니다.",
-};
-
-interface LegalWorksPageProps {
- searchParams: Promise<SearchParams>;
-}
-
-export default async function LegalWorksPage({ searchParams }: LegalWorksPageProps) {
- const rawParams = await searchParams;
- const parsedSearch = SearchParamsCacheLegalWorks.parse(rawParams);
-
- // ✅ EvaluationTargetsPage와 동일한 패턴으로 currentYear 추가
- const currentYear = new Date().getFullYear();
-
- const promises = Promise.all([
- getLegalWorks(parsedSearch)
- ]);
-
- return (
- <Shell className="gap-4">
- {/* Header - EvaluationTargetsPage와 동일한 패턴 */}
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">법무검토 관리</h2>
- <InformationButton pagePath="evcp/legal-review" />
- {/* ✅ EvaluationTargetsPage와 동일하게 Badge 추가 */}
- <Badge variant="outline" className="text-sm">
- {currentYear}년
- </Badge>
- </div>
- </div>
-
- {/* Table */}
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={13}
- searchableColumnCount={3}
- filterableColumnCount={4}
- cellWidths={[
- "3rem", // checkbox
- "4rem", // No.
- "5rem", // 구분
- "6rem", // 상태
- "8rem", // Vendor Code
- "12rem", // Vendor Name
- "4rem", // 긴급여부
- "7rem", // 답변요청일
- "7rem", // 의뢰일
- "7rem", // 답변예정일
- "7rem", // 법무완료일
- "8rem", // 검토요청자
- "8rem", // 법무답변자
- "4rem", // 첨부파일
- "8rem", // actions
- ]}
- shrinkZero
- />
- }
- >
- {/* ✅ currentYear prop 추가 - EvaluationTargetsTable과 동일한 패턴 */}
- <LegalWorksTable
- promises={promises}
- currentYear={currentYear}
- />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/cbe/page.tsx b/app/[lng]/partners/(partners)/cbe/page.tsx
deleted file mode 100644
index 4655cb60..00000000
--- a/app/[lng]/partners/(partners)/cbe/page.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-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"
-import { InformationButton } from "@/components/information/information-button"
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- CBE 관리
- </h2>
- <InformationButton pagePath="partners/cbe" />
- </div>
- {/* <p className="text-sm text-muted-foreground">
- CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "}
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <CbeVendorTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx b/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx
deleted file mode 100644
index 898dc41b..00000000
--- a/app/[lng]/partners/(partners)/rfq-answer/[vendorId]/[rfqRecordId]/page.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-// app/vendor/responses/[vendorId]/[rfqRecordId]/[rfqType]/page.tsx
-import * as React from "react";
-import Link from "next/link";
-import { Metadata } from "next";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Progress } from "@/components/ui/progress";
-import {
- ArrowLeft,
- FileText,
- AlertTriangle,
- TrendingUp,
- CheckCircle2,
- RefreshCw,
- GitBranch,
- Clock,
- FileCheck,
- Calendar
-} from "lucide-react";
-import { Shell } from "@/components/shell";
-import { formatDate } from "@/lib/utils";
-import { getRfqAttachmentResponsesWithRevisions } from "@/lib/b-rfq/service";
-import { FinalRfqResponseTable } from "@/lib/b-rfq/vendor-response/response-detail-table";
-
-export const metadata: Metadata = {
- title: "RFQ 응답 상세",
- description: "RFQ 첨부파일별 응답 관리 - 고급 리비전 추적",
-};
-
-interface RfqResponseDetailPageProps {
- params: Promise<{
- vendorId: string;
- rfqRecordId: string;
- }>;
-}
-
-export default async function RfqResponseDetailPage(props: RfqResponseDetailPageProps) {
- const params = await props.params;
- const { vendorId, rfqRecordId, rfqType } = params;
-
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user) {
- return (
- <Shell className="gap-6">
- <div className="text-center py-12">
- <p className="text-muted-foreground">로그인이 필요합니다.</p>
- </div>
- </Shell>
- );
- }
-
- // 벤더 권한 확인
- if (session.user.domain !== "partners" || String(session.user.companyId) !== vendorId) {
- return (
- <Shell className="gap-6">
- <div className="text-center py-12">
- <p className="text-muted-foreground">접근 권한이 없습니다.</p>
- </div>
- </Shell>
- );
- }
-
- // 데이터 조회 (뷰 기반 고급 리비전 정보 포함)
- const { data: responses, rfqInfo, vendorInfo, statistics, progressSummary } =
- await getRfqAttachmentResponsesWithRevisions(vendorId, rfqRecordId);
-
- console.log("Enhanced RFQ Data:", { responses, statistics, progressSummary });
-
- if (!rfqInfo) {
- return (
- <Shell className="gap-6">
- <div className="text-center py-12">
- <p className="text-muted-foreground">RFQ 정보를 찾을 수 없습니다.</p>
- </div>
- </Shell>
- );
- }
-
- const stats = statistics;
-
- return (
- <Shell className="gap-6">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <Button variant="ghost" size="sm" asChild>
- <Link href="/partners/rfq-answer">
- <ArrowLeft className="h-4 w-4 mr-2" />
- 돌아가기
- </Link>
- </Button>
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {rfqInfo.rfqCode} - RFQ 응답 관리
- </h2>
- <p className="text-muted-foreground">
- 고급 리비전 추적 및 응답 상태 관리
- </p>
- </div>
- </div>
-
- {/* 마감일 표시 */}
- {progressSummary?.daysToDeadline !== undefined && (
- <div className="text-right">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Calendar className="h-4 w-4" />
- <span>마감까지</span>
- </div>
- <div className={`text-lg font-bold ${
- progressSummary.daysToDeadline < 0
- ? 'text-red-600'
- : progressSummary.daysToDeadline <= 3
- ? 'text-orange-600'
- : 'text-green-600'
- }`}>
- {progressSummary.daysToDeadline < 0
- ? `${Math.abs(progressSummary.daysToDeadline)}일 초과`
- : `${progressSummary.daysToDeadline}일 남음`
- }
- </div>
- </div>
- )}
- </div>
-
- {/* 중요 알림들 */}
- <div className="space-y-3">
- {stats.versionMismatch > 0 && (
- <Alert className="border-blue-200 bg-blue-50">
- <RefreshCw className="h-4 w-4 text-blue-600" />
- <AlertDescription className="text-blue-800">
- <strong>{stats.versionMismatch}개 항목</strong>에서 발주처의 최신 리비전과 응답 리비전이 일치하지 않습니다.
- 최신 버전으로 업데이트를 권장합니다.
- </AlertDescription>
- </Alert>
- )}
-
- {progressSummary?.daysToDeadline !== undefined && progressSummary.daysToDeadline <= 3 && progressSummary.daysToDeadline >= 0 && (
- <Alert className="border-orange-200 bg-orange-50">
- <Clock className="h-4 w-4 text-orange-600" />
- <AlertDescription className="text-orange-800">
- 마감일이 <strong>{progressSummary.daysToDeadline}일</strong> 남았습니다.
- 미응답 항목({stats.pending}개)의 신속한 처리가 필요합니다.
- </AlertDescription>
- </Alert>
- )}
-
- {progressSummary?.attachmentsWithMultipleRevisions > 0 && (
- <Alert className="border-purple-200 bg-purple-50">
- <GitBranch className="h-4 w-4 text-purple-600" />
- <AlertDescription className="text-purple-800">
- <strong>{progressSummary.attachmentsWithMultipleRevisions}개 첨부파일</strong>에
- 다중 리비전이 있습니다. 히스토리를 확인하여 올바른 버전으로 응답해주세요.
- </AlertDescription>
- </Alert>
- )}
- </div>
- <FinalRfqResponseTable
- data={responses}
- statistics={stats}
- showHeader={true}
- title="첨부파일별 응답 현황"
- />
-
-
-
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-answer/page.tsx b/app/[lng]/partners/(partners)/rfq-answer/page.tsx
deleted file mode 100644
index 6eae491e..00000000
--- a/app/[lng]/partners/(partners)/rfq-answer/page.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-// app/vendor/responses/page.tsx
-import * as React from "react";
-import Link from "next/link";
-import { Metadata } from "next";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { LogIn, FileX, Clock, CheckCircle, AlertTriangle } from "lucide-react";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { Shell } from "@/components/shell";
-import { getValidFilters } from "@/lib/data-table";
-import { type SearchParams } from "@/types/table";
-import { searchParamsVendorResponseCache } from "@/lib/b-rfq/validations";
-import { getVendorResponseProgress, getVendorResponseStatusCounts, getVendorRfqResponses } from "@/lib/b-rfq/service";
-import { VendorResponsesTable } from "@/lib/b-rfq/vendor-response/vendor-responses-table";
-import { InformationButton } from "@/components/information/information-button"
-export const metadata: Metadata = {
- title: "응답 관리",
- description: "RFQ 첨부파일 응답 현황을 관리합니다",
-};
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsVendorResponseCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- // 로그인 확인
- if (!session || !session.user) {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 응답 관리
- </h2>
- <InformationButton pagePath="partners/rfq-answer" />
- </div>
- {/* <p className="text-muted-foreground">
- RFQ 첨부파일 응답 현황을 확인하고 관리합니다.
- </p> */}
- </div>
- </div>
-
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 응답 현황을 확인하려면 먼저 로그인하세요.
- </p>
- <Button size="lg" asChild>
- <Link href="/partners?callbackUrl=/vendor/responses">
- <LogIn className="mr-2 h-4 w-4" />
- 로그인하기
- </Link>
- </Button>
- </div>
- </div>
- </Shell>
- );
- }
-
- // 벤더 ID 확인
- const vendorId = session.user.companyId ? String(session.user.companyId) : "0";
-
- // 벤더 권한 확인
- if (session.user.domain !== "partners") {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 접근 권한 없음
- </h2>
- </div>
- </div>
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 벤더 계정으로 로그인해주세요.
- </p>
- </div>
- </div>
- </Shell>
- );
- }
-
- // 데이터 가져오기
- const responsesPromise = getVendorRfqResponses({
- ...search,
- filters: validFilters
- }, vendorId);
-
- // 상태별 개수 및 진행률 가져오기
- const [statusCounts, progress] = await Promise.all([
- getVendorResponseStatusCounts(vendorId),
- getVendorResponseProgress(vendorId)
- ]);
-
- // 프로미스 배열
- const promises = Promise.all([responsesPromise]);
-
- return (
- <Shell className="gap-6">
- <div className="flex justify-between items-center">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">RFQ 응답 관리</h2>
- <p className="text-muted-foreground">
- RFQ 첨부파일 응답 현황을 확인하고 관리합니다.
- </p>
- </div>
- </div>
-
- {/* 상태별 통계 카드 */}
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">전체 요청</CardTitle>
- <FileX className="h-4 w-4 text-muted-foreground" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{progress.totalRequests}건</div>
- <p className="text-xs text-muted-foreground">
- 총 응답 요청 수
- </p>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">미응답</CardTitle>
- <Clock className="h-4 w-4 text-orange-600" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-orange-600">
- {statusCounts.NOT_RESPONDED || 0}건
- </div>
- <p className="text-xs text-muted-foreground">
- 응답 대기 중
- </p>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">응답완료</CardTitle>
- <CheckCircle className="h-4 w-4 text-green-600" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-green-600">
- {statusCounts.RESPONDED || 0}건
- </div>
- <p className="text-xs text-muted-foreground">
- 응답률: {progress.responseRate}%
- </p>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">수정요청</CardTitle>
- <AlertTriangle className="h-4 w-4 text-yellow-600" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-yellow-600">
- {statusCounts.REVISION_REQUESTED || 0}건
- </div>
- <p className="text-xs text-muted-foreground">
- 재검토 필요
- </p>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">포기</CardTitle>
- <FileX className="h-4 w-4 text-gray-600" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-gray-600">
- {statusCounts.WAIVED || 0}건
- </div>
- <p className="text-xs text-muted-foreground">
- 완료율: {progress.completionRate}%
- </p>
- </CardContent>
- </Card>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "12rem", "8rem", "10rem", "10rem", "8rem", "10rem", "8rem"]}
- />
- }
- >
- <VendorResponsesTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx
deleted file mode 100644
index 5b52e4a4..00000000
--- a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-// app/vendor/quotations/[id]/page.tsx - 견적 응답 페이지
-import { Metadata } from "next"
-import { notFound } from "next/navigation"
-import db from "@/db/db";
-import { eq } from "drizzle-orm"
-import { procurementVendorQuotations } from "@/db/schema"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import VendorQuotationEditor from "@/lib/procurement-rfqs/vendor-response/quotation-editor";
-
-
-interface PageProps {
- params: Promise<{
- id: string
- }>
-}
-
-export async function generateMetadata(props: PageProps): Promise<Metadata> {
- return {
- title: "견적서 응답",
- description: "RFQ에 대한 견적서 작성 및 제출",
- }
-}
-
-export default async function VendorQuotationPage(props: PageProps) {
- const params = await props.params
- const quotationId = parseInt(params.id)
-
- if (isNaN(quotationId)) {
- notFound()
- }
-
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return (
- <div className="flex h-full items-center justify-center">
- <div className="text-center">
- <h2 className="text-xl font-bold">로그인이 필요합니다</h2>
- <p className="mt-2 text-muted-foreground">견적서 응답을 위해 로그인해주세요.</p>
- </div>
- </div>
- )
- }
-
- // 견적서 정보 가져오기
- const quotation = await db.query.procurementVendorQuotations.findFirst({
- where: eq(procurementVendorQuotations.id, quotationId),
- with: {
- rfq: true, // 관계 설정 필요
- vendor: true, // 관계 설정 필요
- items: true, // 관계 설정 필요
- }
- })
-
- if (!quotation) {
- notFound()
- }
-
- // 벤더 권한 확인 (필요한 경우)
- const isAuthorized = session.user.domain === "partners" &&
- session.user.companyId === quotation.vendorId
-
- if (!isAuthorized) {
- return (
- <div className="flex h-full items-center justify-center">
- <div className="text-center">
- <h2 className="text-xl font-bold">접근 권한이 없습니다</h2>
- <p className="mt-2 text-muted-foreground">이 견적서에 대한 권한이 없습니다.</p>
- </div>
- </div>
- )
- }
-
- return (
- <div className="container py-8">
- <VendorQuotationEditor quotation={quotation} />
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/rfq-ship/page.tsx
deleted file mode 100644
index 332cca2d..00000000
--- a/app/[lng]/partners/(partners)/rfq-ship/page.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-// app/vendor/quotations/page.tsx
-import * as React from "react";
-import Link from "next/link";
-import { Metadata } from "next";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { LogIn } from "lucide-react";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { Shell } from "@/components/shell";
-import { getValidFilters } from "@/lib/data-table";
-import { type SearchParams } from "@/types/table";
-import { searchParamsVendorRfqCache } from "@/lib/procurement-rfqs/validations";
-import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/procurement-rfqs/services";
-import { VendorQuotationsTable } from "@/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table";
-import { InformationButton } from "@/components/information/information-button"
-export const metadata: Metadata = {
- title: "견적 목록",
- description: "진행 중인 견적서 목록",
-};
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsVendorRfqCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- // 로그인 확인
- if (!session || !session.user) {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 목록
- </h2>
- <InformationButton pagePath="partners/rfq-ship" />
- </div>
- {/* <p className="text-muted-foreground">
- 진행 중인 견적서 목록을 확인하고 관리합니다.
- </p> */}
- </div>
- </div>
-
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 견적서를 확인하려면 먼저 로그인하세요.
- </p>
- <Button size="lg" asChild>
- <Link href="/partners?callbackUrl=/vendor/quotations">
- <LogIn className="mr-2 h-4 w-4" />
- 로그인하기
- </Link>
- </Button>
- </div>
- </div>
- </Shell>
- );
- }
-
- // 벤더 ID 확인
- const vendorId = session.user.companyId ? String(session.user.companyId) : "0";
-
- // 벤더 권한 확인
- if (session.user.domain !== "partners") {
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 접근 권한 없음
- </h2>
- </div>
- </div>
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 벤더 계정으로 로그인해주세요.
- </p>
- </div>
- </div>
- </Shell>
- );
- }
-
- // 데이터 가져오기
- const quotationsPromise = getVendorQuotations({
- ...search,
- filters: validFilters
- }, vendorId);
-
- // 상태별 개수 가져오기
- const statusCountsPromise = getQuotationStatusCounts(vendorId);
-
- // 모든 프로미스 병렬 실행
- const promises = Promise.all([quotationsPromise]);
- const statusCounts = await statusCountsPromise;
-
- return (
- <Shell className="gap-6">
- <div className="flex justify-between items-center">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">견적 목록</h2>
- <p className="text-muted-foreground">
- 진행 중인 견적서 목록을 확인하고 관리합니다.
- </p>
- </div>
- </div>
-
- <div className="grid gap-4 md:grid-cols-4">
- <Card>
- <CardHeader className="py-4">
- <CardTitle className="text-base">전체 견적</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">
- {Object.values(statusCounts).reduce((sum, count) => sum + count, 0)}건
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader className="py-4">
- <CardTitle className="text-base">작성 중</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{statusCounts.Draft || 0}건</div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader className="py-4">
- <CardTitle className="text-base">제출됨</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">
- {(statusCounts.Submitted || 0) + (statusCounts.Revised || 0)}건
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader className="py-4">
- <CardTitle className="text-base">승인됨</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{statusCounts.Accepted || 0}건</div>
- </CardContent>
- </Card>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={7}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "10rem", "8rem", "10rem", "10rem", "10rem", "8rem"]}
- />
- }
- >
- <VendorQuotationsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq/page.tsx b/app/[lng]/partners/(partners)/rfq/page.tsx
deleted file mode 100644
index 5cdb1dde..00000000
--- a/app/[lng]/partners/(partners)/rfq/page.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-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 { searchParamsRfqsForVendorsCache } from "@/lib/rfqs/validations"
-import { RfqsVendorTable } from "@/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table"
-import { getServerSession } from "next-auth"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import Link from "next/link"
-import { Button } from "@/components/ui/button"
-import { LogIn } from "lucide-react"
-import { getRfqResponsesForVendor } from "@/lib/vendor-rfq-response/service"
-import { InformationButton } from "@/components/information/information-button"
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsRfqsForVendorsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // Get session
- const session = await getServerSession(authOptions)
-
- // Check if user is logged in
- if (!session || !session.user) {
- // Return login required UI instead of redirecting
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- RFQ
- </h2>
- {/* <p className="text-muted-foreground">
- RFQ를 응답하고 커뮤니케이션을 할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- RFQ를 확인하려면 먼저 로그인하세요.
- </p>
- <Button size="lg" asChild>
- <Link href="/partners">
- <LogIn className="mr-2 h-4 w-4" />
- 로그인하기
- </Link>
- </Button>
- </div>
- </div>
- </Shell>
- )
- }
-
- // User is logged in, proceed with vendor ID
- const vendorId = session.user.companyId
-
- // Validate vendorId (should be a number)
- const idAsNumber = Number(vendorId)
-
- if (isNaN(idAsNumber)) {
- // Handle invalid vendor ID (this shouldn't happen if authentication is working properly)
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- RFQ
- </h2>
- </div>
- </div>
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">계정 오류</h3>
- <p className="mb-6 text-muted-foreground">
- 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요.
- </p>
- </div>
- </div>
- </Shell>
- )
- }
-
- // If we got here, we have a valid vendor ID
- const promises = Promise.all([
- getRfqResponsesForVendor({
- ...search,
- filters: validFilters,
- }, idAsNumber)
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- RFQ
- </h2>
- <InformationButton pagePath="partners/rfq" />
- </div>
- {/* <p className="text-muted-foreground">
- RFQ를 응답하고 커뮤니케이션을 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker can go here */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsVendorTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/tbe/page.tsx b/app/[lng]/partners/(partners)/tbe/page.tsx
deleted file mode 100644
index 38c24624..00000000
--- a/app/[lng]/partners/(partners)/tbe/page.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBEforVendor } from "@/lib/rfqs/service"
-import { searchParamsTBECache } 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 { InformationButton } from "@/components/information/information-button"
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(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 = searchParamsTBECache.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([
- getTBEforVendor({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- <InformationButton pagePath="partners/tbe" />
- </div>
- {/* <p className="text-sm text-muted-foreground">
- TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "}
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TbeVendorTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx
deleted file mode 100644
index e69de29b..00000000
--- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/final/page.tsx
+++ /dev/null
diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx
deleted file mode 100644
index 1af65fbc..00000000
--- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/initial/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table"
-import { getInitialRfqDetail } from "@/lib/b-rfq/service"
-import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsInitialRfqDetailCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = getInitialRfqDetail({
- ...search,
- filters: validFilters,
- }, idAsNumber)
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Initial RFQ List
- </h3>
- <p className="text-sm text-muted-foreground">
- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다.
- </p>
- </div>
- <Separator />
- <div>
- <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx
deleted file mode 100644
index d6836437..00000000
--- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import { RfqDashboardView } from "@/db/schema"
-import { findBRfqById } from "@/lib/b-rfq/service"
-
-export const metadata: Metadata = {
- title: "견적 RFQ 상세",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqDashboardView | null = await findBRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "견적/입찰 문서관리",
- href: `/${lng}/evcp/b-rfq/${id}`,
- },
- {
- title: "Initial RFQ 발송",
- href: `/${lng}/evcp/b-rfq/${id}/initial`,
- },
- {
- title: "Final RFQ 발송",
- href: `/${lng}/evcp/b-rfq/${id}/final`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/b-rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- PR발행 전 RFQ를 생성하여 관리하는 화면입니다.
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx
deleted file mode 100644
index 26dc45fb..00000000
--- a/app/[lng]/procurement/(procurement)/b-rfq/[id]/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations"
-import { getRfqAttachments } from "@/lib/b-rfq/service"
-import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqAttachmentsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = getRfqAttachments({
- ...search,
- filters: validFilters,
- }, idAsNumber)
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 견적 RFQ 문서관리
- </h3>
- <p className="text-sm text-muted-foreground">
- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다.
- </p>
- </div>
- <Separator />
- <div>
- <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/b-rfq/page.tsx b/app/[lng]/procurement/(procurement)/b-rfq/page.tsx
deleted file mode 100644
index a66d7b58..00000000
--- a/app/[lng]/procurement/(procurement)/b-rfq/page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations"
-import { getRFQDashboard } from "@/lib/b-rfq/service"
-import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table"
-
-export const metadata: Metadata = {
- title: "견적 RFQ",
- description: "",
-}
-
-interface PQReviewPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsRFQDashboardCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getRFQDashboard({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- console.log(search, "견적")
-
- return (
- <Shell className="gap-4">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 RFQ
- </h2>
- </div>
- </div>
- </div>
-
- {/* Items처럼 직접 테이블 렌더링 */}
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQDashboardTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx b/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx
deleted file mode 100644
index 26108323..00000000
--- a/app/[lng]/procurement/(procurement)/basic-contract-template/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 기본 계약문서 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 기본계약서를 비롯하여 초기 서명이 필요한 문서를 등록하고 편집할 수 있습니다. 활성화된 템플릿이 서명 요청의 리스트에 나타나게 됩니다..{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <BasicContractTemplateTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/basic-contract/page.tsx b/app/[lng]/procurement/(procurement)/basic-contract/page.tsx
deleted file mode 100644
index 19211d4e..00000000
--- a/app/[lng]/procurement/(procurement)/basic-contract/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 기본계약서 서명 현황
- </h2>
- {/* <p className="text-muted-foreground">
- 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명된 문서들을 다운로드할 수 있습니다. {" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <BasicContractsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/bqcbe/page.tsx b/app/[lng]/procurement/(procurement)/bqcbe/page.tsx
deleted file mode 100644
index 831bb5a8..00000000
--- a/app/[lng]/procurement/(procurement)/bqcbe/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
- 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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- CBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllCbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/bqtbe/page.tsx b/app/[lng]/procurement/(procurement)/bqtbe/page.tsx
deleted file mode 100644
index 3e56cfaa..00000000
--- a/app/[lng]/procurement/(procurement)/bqtbe/page.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-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<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqTBEPage(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 = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- }
- )
- ])
-
- // 4) 렌더링
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 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([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx
deleted file mode 100644
index 2b80e64f..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-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 { 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",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary-rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary-rfq/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx
deleted file mode 100644
index f342bbff..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary-rfq/page.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.PURCHASE_BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 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([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx
deleted file mode 100644
index d58d8363..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-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 { 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",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary Quote 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
-
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/budgetary/page.tsx b/app/[lng]/procurement/(procurement)/budgetary/page.tsx
deleted file mode 100644
index 15b4cdd4..00000000
--- a/app/[lng]/procurement/(procurement)/budgetary/page.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/dashboard/page.tsx b/app/[lng]/procurement/(procurement)/dashboard/page.tsx
deleted file mode 100644
index 1d61dc16..00000000
--- a/app/[lng]/procurement/(procurement)/dashboard/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-// app/invalid-access/page.tsx
-
-export default function InvalidAccessPage() {
- return (
- <main style={{ padding: '40px', textAlign: 'center' }}>
- <h1>부적절한 접근입니다</h1>
- <p>
- 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br />
- SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다.
- </p>
- <p>
- <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong>
- </p>
- </main>
- );
- }
- \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/equip-class/page.tsx b/app/[lng]/procurement/(procurement)/equip-class/page.tsx
deleted file mode 100644
index 34fd32b6..00000000
--- a/app/[lng]/procurement/(procurement)/equip-class/page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-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/equip-class/validation"
-import { FormListsTable } from "@/lib/form-list/table/formLists-table"
-import { getTagClassists } from "@/lib/equip-class/service"
-import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getTagClassists({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 객체 클래스 목록 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- 객체 클래스 목록을 확인할 수 있습니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <EquipClassTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx b/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx
deleted file mode 100644
index 8bccd3b7..00000000
--- a/app/[lng]/procurement/(procurement)/esg-check-list/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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 { getEsgEvaluations } from "@/lib/esg-check-list/service"
-import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation"
-import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = getEsgEvaluationsSchema.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getEsgEvaluations({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- ESG 자가진단평가서 항목 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <EsgEvaluationsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx
deleted file mode 100644
index 45da961b..00000000
--- a/app/[lng]/procurement/(procurement)/evaluation-check-list/page.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-/* IMPORT */
-import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
-import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
-import { getValidFilters } from '@/lib/data-table';
-import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
-import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
-import { Shell } from '@/components/shell';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Suspense } from 'react';
-import { type SearchParams } from '@/types/table';
-
-// ----------------------------------------------------------------------------------------------------
-
-/* TYPES */
-interface EvaluationCriteriaPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* REGULAR EVALUATION CRITERIA PAGE */
-async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
- const promises = Promise.all([
- getRegEvalCriteria({
- ...search,
- filters: validFilters,
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가기준표 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 평가기준표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </Suspense>
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={11}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RegEvalCriteriaTable promises={promises} />
- </Suspense>
- </Shell>
- )
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* EXPORT */
-export default EvaluationCriteriaPage; \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx
deleted file mode 100644
index 3a403620..00000000
--- a/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { EvaluationPage } from "@/lib/evaluation-submit/evaluation-page"
-import { Metadata } from "next"
-
-export const metadata: Metadata = {
- title: "평가 작성",
- description: "협력업체 평가를 작성합니다",
-}
-
-interface PageProps {
- params: {
- id: string
- }
-}
-
-export default function Page({ params }: PageProps) {
- return <EvaluationPage />
-}
-
-export async function generateStaticParams() {
- // 동적 경로이므로 빈 배열 반환
- return []
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx
deleted file mode 100644
index 00f1820f..00000000
--- a/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-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 { getServerSession } from "next-auth"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import Link from "next/link"
-import { Button } from "@/components/ui/button"
-import { LogIn } from "lucide-react"
-import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service"
-import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation"
-import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = getSHIEvaluationsSubmitSchema.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // Get session
- const session = await getServerSession(authOptions)
-
- // Check if user is logged in
- if (!session || !session.user) {
- // Return login required UI instead of redirecting
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 정기평가
- </h2>
- </div>
- {/* <p className="text-muted-foreground">
- 요청된 정기평가를 입력하고 제출할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
- <p className="mb-6 text-muted-foreground">
- 정기평가를 확인하려면 먼저 로그인하세요.
- </p>
- <Button size="lg" asChild>
- <Link href="/partners">
- <LogIn className="mr-2 h-4 w-4" />
- 로그인하기
- </Link>
- </Button>
- </div>
- </div>
- </Shell>
- )
- }
-
- const userId = session.user.id
-
- // Validate vendorId (should be a number)
- const idAsNumber = Number(userId)
-
-
- if (isNaN(idAsNumber)) {
- // Handle invalid vendor ID (this shouldn't happen if authentication is working properly)
- return (
- <Shell className="gap-6">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 정기평가
- </h2>
- </div>
- </div>
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="rounded-lg border border-dashed p-10 shadow-sm">
- <h3 className="mb-2 text-xl font-semibold">계정 오류</h3>
- <p className="mb-6 text-muted-foreground">
- 관리자에게 문의하세요.
- </p>
- </div>
- </div>
- </Shell>
- )
- }
-
- // If we got here, we have a valid vendor ID
- const promises = Promise.all([
- getSHIEvaluationSubmissions({
- ...search,
- filters: validFilters,
- }, idAsNumber)
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 정기평가
- </h2>
- {/* <p className="text-muted-foreground">
- 요청된 정기평가를 입력하고 제출할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker can go here */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <SHIEvaluationSubmissionsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx
deleted file mode 100644
index a0523eea..00000000
--- a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { HelpCircle } from "lucide-react"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-
-import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation"
-import { getEvaluationTargets } from "@/lib/evaluation-target-list/service"
-import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table"
-import { InformationButton } from "@/components/information/information-button"
-export const metadata: Metadata = {
- title: "협력업체 평가 대상 관리",
- description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.",
-}
-
-interface EvaluationTargetsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-
-
-export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsEvaluationTargetsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // 현재 평가년도 (필터에서 가져오거나 기본값 사용)
- const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getEvaluationTargets({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- {/* 간소화된 헤더 */}
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center gap-2">
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가 대상 관리
- </h2>
- <InformationButton pagePath="evcp/evaluation-target-list" />
- </div>
- <Badge variant="outline" className="text-sm">
- {currentEvaluationYear}년도
- </Badge>
-
- </div>
- </div>
- </div>
-
- {/* 메인 테이블 (통계는 테이블 내부로 이동) */}
- <React.Suspense
- key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={6}
- cellWidths={[
- "3rem", // checkbox
- "5rem", // 평가년도
- "4rem", // 구분
- "8rem", // 벤더코드
- "12rem", // 벤더명
- "4rem", // 내외자
- "6rem", // 자재구분
- "5rem", // 상태
- "5rem", // 의견일치
- "8rem", // 담당자현황
- "10rem", // 관리자의견
- "8rem" // actions
- ]}
- shrinkZero
- />
- }
- >
- {currentEvaluationYear &&
- <EvaluationTargetsTable
- promises={promises}
- evaluationYear={currentEvaluationYear}
- />
-}
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/evaluation/page.tsx b/app/[lng]/procurement/(procurement)/evaluation/page.tsx
deleted file mode 100644
index 2d8cbed7..00000000
--- a/app/[lng]/procurement/(procurement)/evaluation/page.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-// ================================================================
-// 4. PERIODIC EVALUATIONS PAGE
-// ================================================================
-
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { HelpCircle } from "lucide-react"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table"
-import { getPeriodicEvaluations } from "@/lib/evaluation/service"
-import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation"
-
-export const metadata: Metadata = {
- title: "협력업체 정기평가",
- description: "협력업체 정기평가 진행 현황을 관리합니다.",
-}
-
-interface PeriodicEvaluationsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// 프로세스 안내 팝오버 컴포넌트
-function ProcessGuidePopover() {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon" className="h-6 w-6">
- <HelpCircle className="h-4 w-4 text-muted-foreground" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-96" align="start">
- <div className="space-y-3">
- <div className="space-y-1">
- <h4 className="font-medium">정기평가 프로세스</h4>
- {/* <p className="text-sm text-muted-foreground">
- 확정된 평가 대상 업체들에 대한 정기평가 절차입니다.
- </p> */}
- </div>
- <div className="space-y-3 text-sm">
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 1
- </div>
- <div>
- <p className="font-medium">평가 대상 확정</p>
- <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 2
- </div>
- <div>
- <p className="font-medium">업체 자료 제출</p>
- <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 3
- </div>
- <div>
- <p className="font-medium">평가자 검토</p>
- <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 4
- </div>
- <div>
- <p className="font-medium">최종 확정</p>
- <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p>
- </div>
- </div>
- </div>
- </div>
- </PopoverContent>
- </Popover>
- )
-}
-
-// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함
-function getDefaultEvaluationYear() {
- return new Date().getFullYear()
-}
-
-
-
-export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsEvaluationsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters || [])
-
- // 기본 필터 처리
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // 현재 평가년도
- const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getPeriodicEvaluations({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- {/* 헤더 */}
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가
- </h2>
- <Badge variant="outline" className="text-sm">
- {currentEvaluationYear}년도
- </Badge>
- </div>
- </div>
- </div>
-
- {/* 메인 테이블 */}
- <React.Suspense
- key={JSON.stringify(searchParams)}
- fallback={
- <DataTableSkeleton
- columnCount={15}
- searchableColumnCount={2}
- filterableColumnCount={8}
- cellWidths={[
- "3rem", // checkbox
- "5rem", // 평가년도
- "5rem", // 평가기간
- "4rem", // 구분
- "8rem", // 벤더코드
- "12rem", // 벤더명
- "4rem", // 내외자
- "6rem", // 자재구분
- "5rem", // 문서제출
- "4rem", // 제출일
- "4rem", // 마감일
- "4rem", // 총점
- "4rem", // 등급
- "5rem", // 진행상태
- "8rem" // actions
- ]}
- shrinkZero
- />
- }
- >
- <PeriodicEvaluationsTable
- promises={promises}
- evaluationYear={currentEvaluationYear}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/faq/manage/actions.ts b/app/[lng]/procurement/(procurement)/faq/manage/actions.ts
deleted file mode 100644
index bc443a8a..00000000
--- a/app/[lng]/procurement/(procurement)/faq/manage/actions.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/faq/manage/page.tsx b/app/[lng]/procurement/(procurement)/faq/manage/page.tsx
deleted file mode 100644
index 011bbfa4..00000000
--- a/app/[lng]/procurement/(procurement)/faq/manage/page.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/faq/page.tsx b/app/[lng]/procurement/(procurement)/faq/page.tsx
deleted file mode 100644
index 00956591..00000000
--- a/app/[lng]/procurement/(procurement)/faq/page.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ</h2>
- {/* <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p> */}
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- FAQ 관리
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/incoterms/page.tsx b/app/[lng]/procurement/(procurement)/incoterms/page.tsx
deleted file mode 100644
index 804bc5af..00000000
--- a/app/[lng]/procurement/(procurement)/incoterms/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { type SearchParams } from "@/types/table";
-import { getValidFilters } from "@/lib/data-table";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { SearchParamsCache } from "@/lib/incoterms/validations";
-import { getIncoterms } from "@/lib/incoterms/service";
-import { IncotermsTable } from "@/lib/incoterms/table/incoterms-table";
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>;
-}
-
-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([
- getIncoterms({
- ...search,
- filters: validFilters,
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">인코텀즈 관리</h2>
- {/* <p className="text-muted-foreground">
- 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <IncotermsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/items-tech/layout.tsx b/app/[lng]/procurement/(procurement)/items-tech/layout.tsx
deleted file mode 100644
index d375059b..00000000
--- a/app/[lng]/procurement/(procurement)/items-tech/layout.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/items-tech/page.tsx b/app/[lng]/procurement/(procurement)/items-tech/page.tsx
deleted file mode 100644
index 55ac9c63..00000000
--- a/app/[lng]/procurement/(procurement)/items-tech/page.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
- const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
- const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
- const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
- const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
- const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
-
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...shipbuildingSearch,
- filters: validShipbuildingFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...offshoreTopSearch,
- filters: validOffshoreTopFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...offshoreHullSearch,
- filters: validOffshoreHullFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/items/page.tsx b/app/[lng]/procurement/(procurement)/items/page.tsx
deleted file mode 100644
index f8d9a5b1..00000000
--- a/app/[lng]/procurement/(procurement)/items/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-// app/items/page.tsx (업데이트)
-import * as React from "react"
-import { type SearchParams } from "@/types/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/items/validations"
-import { getItems } from "@/lib/items/service"
-import { ItemsTable } from "@/lib/items/table/items-table"
-import { ViewModeToggle } from "@/components/data-table/view-mode-toggle"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- // pageSize 기반으로 모드 자동 결정
- const isInfiniteMode = search.perPage >= 1_000_000
-
- // 페이지네이션 모드일 때만 서버에서 데이터 가져오기
- // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드
- const promises = isInfiniteMode
- ? undefined
- : Promise.all([
- getItems(search), // searchParamsCache의 결과를 그대로 사용
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 패키지 넘버
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다.
- </p> */}
- </div>
- </div>
-
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker 등 추가 컴포넌트 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- {/* 통합된 ItemsTable 컴포넌트 사용 */}
- <ItemsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/layout.tsx b/app/[lng]/procurement/(procurement)/layout.tsx
deleted file mode 100644
index 82b53307..00000000
--- a/app/[lng]/procurement/(procurement)/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
-import { SiteFooter } from '@/components/layout/Footer';
-
-export default function EvcpLayout({ children }: { children: ReactNode }) {
- return (
- <div className="relative flex min-h-svh flex-col bg-background">
- {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */}
- <Header />
- <main className="flex flex-1 flex-col">
- <div className='container-wrapper'>
- {children}
- </div>
- </main>
- <SiteFooter/>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/menu-list/page.tsx b/app/[lng]/procurement/(procurement)/menu-list/page.tsx
deleted file mode 100644
index dee45ab1..00000000
--- a/app/[lng]/procurement/(procurement)/menu-list/page.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-// app/evcp/menu-list/page.tsx
-
-import { Suspense } from "react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { RefreshCw, Settings } from "lucide-react";
-import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
-import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
-import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
-import { Shell } from "@/components/shell"
-import * as React from "react"
-
-export default async function MenuListPage() {
- // 초기 데이터 로드
- const [menusResult, usersResult] = await Promise.all([
- getMenuAssignments(),
- getActiveUsers()
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 메뉴 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- </div>
-
-
- <React.Suspense
- fallback={
- ""
- }
- >
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Settings className="h-5 w-5" />
- 메뉴 리스트
- </CardTitle>
- <CardDescription>
- 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다.
- {menusResult.data?.length > 0 && (
- <span className="ml-2 text-sm">
- 총 {menusResult.data.length}개의 메뉴
- </span>
- )}
- </CardDescription>
- </CardHeader>
- <CardContent>
- <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
- <MenuListTable
- initialMenus={menusResult.data || []}
- initialUsers={usersResult.data || []}
- />
- </Suspense>
- </CardContent>
- </Card>
- </React.Suspense>
- </Shell>
-
- );
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx b/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx
deleted file mode 100644
index d001a39d..00000000
--- a/app/[lng]/procurement/(procurement)/payment-conditions/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { type SearchParams } from "@/types/table";
-import { getValidFilters } from "@/lib/data-table";
-import { Shell } from "@/components/shell";
-import { Skeleton } from "@/components/ui/skeleton";
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
-import { SearchParamsCache } from "@/lib/payment-terms/validations";
-import { getPaymentTerms } from "@/lib/payment-terms/service";
-import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-table";
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>;
-}
-
-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([
- getPaymentTerms({
- ...search,
- filters: validFilters,
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">지급 조건 관리</h2>
- {/* <p className="text-muted-foreground">
- 지급 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다.
- </p> */}
- </div>
- </div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PaymentTermsTable promises={promises} />
- </React.Suspense>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/po-rfq/page.tsx b/app/[lng]/procurement/(procurement)/po-rfq/page.tsx
deleted file mode 100644
index 4a04d6a8..00000000
--- a/app/[lng]/procurement/(procurement)/po-rfq/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { getPORfqs } from "@/lib/procurement-rfqs/services"
-import { searchParamsCache } from "@/lib/procurement-rfqs/validations"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: RfqPageProps) {
- // searchParams를 await하여 resolve
- const searchParams = await props.searchParams
-
- // 파라미터 파싱
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
-
- // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달
- const promises = Promise.all([
- getPORfqs({
- ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
- filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
- })
- ])
-
- return (
- <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
- {/* 고정 헤더 영역 */}
- <div className="flex-shrink-0">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- RFQ
- </h2>
- </div>
- </div>
- </div>
-
- {/* 테이블 영역 - 남은 공간 모두 차지 */}
- <div className="flex-1 min-h-0">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQListTable promises={promises} className="h-full" />
- </React.Suspense>
- </div>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/po/page.tsx b/app/[lng]/procurement/(procurement)/po/page.tsx
deleted file mode 100644
index b4dd914f..00000000
--- a/app/[lng]/procurement/(procurement)/po/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-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 { getPOs } from "@/lib/po/service"
-import { searchParamsCache } from "@/lib/po/validations"
-import { PoListsTable } from "@/lib/po/table/po-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getPOs({
- ...search,
- filters: validFilters,
- }),
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- PO 확인 및 전자서명
- </h2>
- {/* <p className="text-muted-foreground">
- 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.
-
- </p> */}
- </div>
- </div>
- </div>
-
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PoListsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/poa/page.tsx b/app/[lng]/procurement/(procurement)/poa/page.tsx
deleted file mode 100644
index 1c244991..00000000
--- a/app/[lng]/procurement/(procurement)/poa/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-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 { getChangeOrders } from "@/lib/poa/service"
-import { searchParamsCache } from "@/lib/poa/validations"
-import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getChangeOrders({
- ...search,
- filters: validFilters,
- }),
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 변경 PO 확인 및 전자서명
- </h2>
- {/* <p className="text-muted-foreground">
- 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ChangeOrderListsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx b/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx
deleted file mode 100644
index 15cb3bf3..00000000
--- a/app/[lng]/procurement/(procurement)/pq-criteria/[pqListId]/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { searchParamsCache } from "@/lib/pq/validations"
-import { getPQsByListId } from "@/lib/pq/service"
-import { PqsTable } from "@/lib/pq/pq-criteria/pq-table"
-import { notFound } from "next/navigation"
-
-interface PQDetailPageProps {
- params: Promise<{ pqListId: string }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQDetailPage(props: PQDetailPageProps) {
- const params = await props.params
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const pqListId = parseInt(params.pqListId)
- if (isNaN(pqListId)) {
- notFound()
- }
-
- // filters가 없는 경우를 처리
- const validFilters = getValidFilters(search.filters)
-
- // PQ 항목들 가져오기
- const promises = Promise.all([
- getPQsByListId(pqListId, {
- ...search,
- filters: validFilters,
- })
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- PQ 항목 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 선택한 PQ 목록의 세부 항목들을 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "20rem", "15rem", "10rem", "10rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PqsTable
- promises={promises}
- pqListId={pqListId}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx b/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx
deleted file mode 100644
index 1a337cc9..00000000
--- a/app/[lng]/procurement/(procurement)/pq-criteria/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { searchParamsCache } from "@/lib/pq/validations"
-import { getPQLists } from "@/lib/pq/service"
-import { PqListsTable } from "@/lib/pq/table/pq-lists-table"
-import { getProjects } from "@/lib/pq/service"
-
-interface ProjectPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function ProjectPage(props: ProjectPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- // filters가 없는 경우를 처리
- const validFilters = getValidFilters(search.filters)
-
- // // 프로젝트별 PQ 데이터 가져오기
- const promises = Promise.all([
- getPQLists({
- ...search,
- filters: validFilters,
- }),
- getProjects()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- PQ 리스트 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PqListsTable
- promises={promises}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx
deleted file mode 100644
index b4b51363..00000000
--- a/app/[lng]/procurement/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import Link from "next/link"
-import { notFound } from "next/navigation"
-import { ArrowLeft } from "lucide-react"
-import { Shell } from "@/components/shell"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Separator } from "@/components/ui/separator"
-import { getPQById, getPQDataByVendorId } from "@/lib/pq/service"
-import { unstable_noStore as noStore } from 'next/cache'
-import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper"
-import { formatDate } from "@/lib/utils"
-
-export const metadata: Metadata = {
- title: "PQ 검토",
- description: "협력업체의 Pre-Qualification 답변을 검토합니다.",
-}
-
-// 페이지가 기본적으로 동적임을 나타냄
-export const dynamic = "force-dynamic"
-
-interface PQReviewPageProps {
- params: Promise<{
- vendorId: string;
- submissionId: string;
- }>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- // 캐시 비활성화
- noStore()
-
- const params = await props.params
- const vendorId = parseInt(params.vendorId, 10)
- const submissionId = parseInt(params.submissionId, 10)
-
- try {
- // PQ Submission 정보 조회
- const pqSubmission = await getPQById(submissionId, vendorId)
-
- // PQ 데이터 조회 (질문과 답변)
- const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined)
-
- // 프로젝트 정보 (프로젝트 PQ인 경우)
- const projectInfo = pqSubmission.projectId ? {
- id: pqSubmission.projectId,
- projectCode: pqSubmission.projectCode || '',
- projectName: pqSubmission.projectName || '',
- status: pqSubmission.status,
- submittedAt: pqSubmission.submittedAt,
- } : null
-
- // PQ 유형 및 상태 레이블
- const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" :
- pqSubmission.type === "PROJECT" ? "프로젝트 PQ" :
- pqSubmission.type === "NON_INSPECTION" ? "미실사 PQ" : "일반 PQ"
- const statusLabel = getStatusLabel(pqSubmission.status)
- const statusVariant = getStatusVariant(pqSubmission.status)
-
- // 수정 가능 여부 (SUBMITTED 상태일 때만 가능)
- const canReview = pqSubmission.status === "SUBMITTED"
-
- return (
- <Shell className="gap-6 max-w-5xl">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <Button variant="outline" size="sm" asChild>
- <Link href="/procurement/pq_new">
- <ArrowLeft className="w-4 h-4 mr-2" />
- 목록으로
- </Link>
- </Button>
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {pqSubmission.vendorName} - {typeLabel}
- </h2>
- <div className="flex items-center gap-2 mt-1">
- <Badge variant={statusVariant}>{statusLabel}</Badge>
- {projectInfo && (
- <span className="text-muted-foreground">
- {projectInfo.projectName} ({projectInfo.projectCode})
- </span>
- )}
- </div>
- </div>
- </div>
- </div>
-
- {/* 상태별 알림 */}
- {pqSubmission.status === "SUBMITTED" && (
- <Alert>
- <AlertTitle>제출 완료</AlertTitle>
- <AlertDescription>
- 협력업체가 {formatDate(pqSubmission.submittedAt, "kr")}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {pqSubmission.status === "APPROVED" && (
- <Alert variant="success">
- <AlertTitle>승인됨</AlertTitle>
- <AlertDescription>
- {formatDate(pqSubmission.approvedAt, "kr")}에 승인되었습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {pqSubmission.status === "REJECTED" && (
- <Alert variant="destructive">
- <AlertTitle>거부됨</AlertTitle>
- <AlertDescription>
- {formatDate(pqSubmission.rejectedAt, "kr")}에 거부되었습니다.
- {pqSubmission.rejectReason && (
- <div className="mt-2">
- <strong>사유:</strong> {pqSubmission.rejectReason}
- </div>
- )}
- </AlertDescription>
- </Alert>
- )}
-
- <Separator />
-
- {/* PQ 검토 컴포넌트 */}
- <Tabs defaultValue="review" className="w-full">
- <TabsList>
- <TabsTrigger value="review">PQ 검토</TabsTrigger>
- <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger>
- </TabsList>
-
- <TabsContent value="review" className="mt-4">
- <PQReviewWrapper
- pqData={pqData}
- vendorId={vendorId}
- pqSubmission={pqSubmission}
- canReview={canReview}
- />
- </TabsContent>
-
- <TabsContent value="vendor-info" className="mt-4">
- <div className="rounded-md border p-4">
- <h3 className="text-lg font-medium mb-4">협력업체 정보</h3>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <p className="text-sm font-medium text-muted-foreground">업체명</p>
- <p>{pqSubmission.vendorName}</p>
- </div>
- <div>
- <p className="text-sm font-medium text-muted-foreground">업체 코드</p>
- <p>{pqSubmission.vendorCode}</p>
- </div>
- <div>
- <p className="text-sm font-medium text-muted-foreground">상태</p>
- <p>{pqSubmission.vendorStatus}</p>
- </div>
- {/* 필요시 추가 정보 표시 */}
- </div>
- </div>
- </TabsContent>
- </Tabs>
- </Shell>
- )
- } catch (error) {
- console.error("Error loading PQ:", error)
- notFound()
- }
-}
-
-// 상태 레이블 함수
-function getStatusLabel(status: string): string {
- switch (status) {
- case "REQUESTED":
- return "요청됨";
- case "IN_PROGRESS":
- return "진행 중";
- case "SUBMITTED":
- return "제출됨";
- case "APPROVED":
- return "승인됨";
- case "REJECTED":
- return "거부됨";
- default:
- return status;
- }
-}
-
-// 상태별 Badge 스타일
-function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" {
- switch (status) {
- case "REQUESTED":
- return "outline";
- case "IN_PROGRESS":
- return "secondary";
- case "SUBMITTED":
- return "default";
- case "APPROVED":
- return "success";
- case "REJECTED":
- return "destructive";
- default:
- return "outline";
- }
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/pq_new/page.tsx b/app/[lng]/procurement/(procurement)/pq_new/page.tsx
deleted file mode 100644
index 6a992ee5..00000000
--- a/app/[lng]/procurement/(procurement)/pq_new/page.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { searchParamsPQReviewCache } from "@/lib/pq/validations"
-import { getPQSubmissions } from "@/lib/pq/service"
-import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table"
-import { InformationButton } from "@/components/information/information-button"
-export const metadata: Metadata = {
- title: "협력업체 PQ/실사 현황",
- description: "",
-}
-
-interface PQReviewPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsPQReviewCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 디버깅 로그 추가
- console.log("=== PQ Page Debug ===");
- console.log("Raw searchParams:", searchParams);
- console.log("Raw basicFilters param:", searchParams.basicFilters);
- console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters);
- console.log("Parsed search:", search);
- console.log("search.filters:", search.filters);
- console.log("search.basicFilters:", search.basicFilters);
- console.log("search.pqBasicFilters:", search.pqBasicFilters);
- console.log("validFilters:", validFilters);
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) {
- // 하위 호환성을 위해 기존 이름도 지원
- basicFilters = search.pqBasicFilters
- console.log("Using search.pqBasicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- console.log("Final allFilters:", allFilters);
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and';
- console.log("Final joinOperator:", joinOperator);
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getPQSubmissions({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 PQ/실사 현황
- </h2>
- <InformationButton pagePath="evcp/pq_new" />
- </div>
- </div>
- </div>
- </div>
-
- {/* Items처럼 직접 테이블 렌더링 */}
- <React.Suspense
- key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <PQSubmissionsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/project-gtc/page.tsx b/app/[lng]/procurement/(procurement)/project-gtc/page.tsx
deleted file mode 100644
index 554f17b0..00000000
--- a/app/[lng]/procurement/(procurement)/project-gtc/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getProjectGtcList } from "@/lib/project-gtc/service"
-import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations"
-import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = projectGtcSearchParamsSchema.parse(searchParams)
-
- const promises = Promise.all([
- getProjectGtcList({
- page: search.page,
- perPage: search.perPage,
- search: search.search,
- sort: search.sort,
- }),
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- Project GTC 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다.
- 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* 추가 기능이 필요하면 여기에 추가 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]}
- shrinkZero
- />
- }
- >
- <ProjectGtcTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/project-vendors/page.tsx b/app/[lng]/procurement/(procurement)/project-vendors/page.tsx
deleted file mode 100644
index 525cff07..00000000
--- a/app/[lng]/procurement/(procurement)/project-vendors/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 AVL 리스트
- </h2>
- {/* <p className="text-muted-foreground">
- 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ProjectAVLTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/projects/page.tsx b/app/[lng]/procurement/(procurement)/projects/page.tsx
deleted file mode 100644
index 8c332c6c..00000000
--- a/app/[lng]/procurement/(procurement)/projects/page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-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 { ItemsTable } from "@/lib/items/table/items-table"
-import { getProjectLists } from "@/lib/projects/service"
-import { ProjectsTable } from "@/lib/projects/table/projects-table"
-import { searchParamsProjectsCache } from "@/lib/projects/validation"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsProjectsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getProjectLists({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 수행 프로젝트 리스트 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ProjectsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/report/page.tsx b/app/[lng]/procurement/(procurement)/report/page.tsx
deleted file mode 100644
index 2782c3ac..00000000
--- a/app/[lng]/procurement/(procurement)/report/page.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import * as React from "react";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Shell } from "@/components/shell";
-import { ErrorBoundary } from "@/components/error-boundary";
-import { getDashboardData } from "@/lib/dashboard/service";
-import { DashboardClient } from "@/lib/dashboard/dashboard-client";
-
-// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생.
-// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리
-// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨.
-export const dynamic = 'force-dynamic'
-
-export default async function IndexPage() {
- // domain을 명시적으로 전달
- const domain = "procurement";
-
- try {
- // 서버에서 직접 데이터 fetch
- const dashboardData = await getDashboardData(domain);
-
- return (
- <Shell className="gap-2">
- <DashboardClient initialData={dashboardData} />
- </Shell>
- );
- } catch (error) {
- console.error("Dashboard data fetch error:", error);
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-center py-12">
- <div className="text-center space-y-2">
- <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p>
- <p className="text-muted-foregroucdnd text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p>
- </div>
- </div>
- </Shell>
- );
- }
-}
-
-function DashboardSkeleton() {
- return (
- <div className="space-y-6">
- {/* 헤더 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="space-y-2">
- <Skeleton className="h-8 w-48" />
- <Skeleton className="h-4 w-72" />
- </div>
- <Skeleton className="h-10 w-24" />
- </div>
-
- {/* 요약 카드 스켈레톤 */}
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
- {[...Array(4)].map((_, i) => (
- <div key={i} className="space-y-3 p-6 border rounded-lg">
- <div className="flex items-center justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-4" />
- </div>
- <Skeleton className="h-8 w-12" />
- <Skeleton className="h-3 w-20" />
- </div>
- ))}
- </div>
-
- {/* 차트 스켈레톤 */}
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
- {[...Array(2)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <div className="space-y-2">
- <Skeleton className="h-6 w-32" />
- <Skeleton className="h-4 w-48" />
- </div>
- <Skeleton className="h-[300px] w-full" />
- </div>
- ))}
- </div>
-
- {/* 탭 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-10 w-64" />
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
- {[...Array(6)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <Skeleton className="h-6 w-32" />
- <div className="space-y-3">
- <div className="flex justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-12" />
- </div>
- <div className="flex gap-2">
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- </div>
- <Skeleton className="h-2 w-full" />
- </div>
- </div>
- ))}
- </div>
- </div>
- </div>
- );
-}
diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx
deleted file mode 100644
index fb288a98..00000000
--- a/app/[lng]/procurement/(procurement)/rfq/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-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에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqCBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 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([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx
deleted file mode 100644
index 92817b4b..00000000
--- a/app/[lng]/procurement/(procurement)/rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-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",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/rfq/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/rfq/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/rfq/${id}/cbe`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx
deleted file mode 100644
index 1a9f4b18..00000000
--- a/app/[lng]/procurement/(procurement)/rfq/[id]/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx
deleted file mode 100644
index 76eea302..00000000
--- a/app/[lng]/procurement/(procurement)/rfq/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/rfq/page.tsx b/app/[lng]/procurement/(procurement)/rfq/page.tsx
deleted file mode 100644
index 26f49cfb..00000000
--- a/app/[lng]/procurement/(procurement)/rfq/page.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-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/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.PURCHASE,
- title = "RFQ",
- description = "RFQ를 등록하고 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/settings/layout.tsx b/app/[lng]/procurement/(procurement)/settings/layout.tsx
deleted file mode 100644
index 6c380919..00000000
--- a/app/[lng]/procurement/(procurement)/settings/layout.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-
-export const metadata: Metadata = {
- title: "Settings",
- // description: "Advanced form example using react-hook-form and Zod.",
-}
-
-
-interface SettingsLayoutProps {
- children: React.ReactNode
- params: { lng: string }
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string }
-}) {
- const resolvedParams = await params
- const lng = resolvedParams.lng
-
-
- const sidebarNavItems = [
-
- {
- title: "Account",
- href: `/${lng}/evcp/settings`,
- },
- {
- title: "Preferences",
- href: `/${lng}/evcp/settings/preferences`,
- }
-
-
- ]
-
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">설정</h2>
- {/* <p className="text-muted-foreground">
- Manage your account settings and preferences.
- </p> */}
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 ">{children}</div>
- </div>
- </div>
- </section>
- </div>
-
-
- </>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/settings/page.tsx b/app/[lng]/procurement/(procurement)/settings/page.tsx
deleted file mode 100644
index eba5e948..00000000
--- a/app/[lng]/procurement/(procurement)/settings/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AccountForm } from "@/components/settings/account-form"
-
-export default function SettingsAccountPage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Account</h3>
- {/* <p className="text-sm text-muted-foreground">
- Update your account settings. Set your preferred language and
- timezone.
- </p> */}
- </div>
- <Separator />
- <AccountForm />
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx b/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx
deleted file mode 100644
index e2a88021..00000000
--- a/app/[lng]/procurement/(procurement)/settings/preferences/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AppearanceForm } from "@/components/settings/appearance-form"
-
-export default function SettingsAppearancePage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Preference</h3>
- <p className="text-sm text-muted-foreground">
- Customize the preference of the app.
- </p>
- </div>
- <Separator />
- <AppearanceForm />
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx b/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx
deleted file mode 100644
index 11a9e9fb..00000000
--- a/app/[lng]/procurement/(procurement)/system/admin-users/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-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 { DateRangePicker } from "@/components/date-range-picker"
-import { Separator } from "@/components/ui/separator"
-
-import { searchParamsCache } from "@/lib/admin-users/validations"
-import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service"
-import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function UserTable(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getUsers({
- ...search,
- filters: validFilters,
- }),
- getUserCountGroupByCompany(),
- getUserCountGroupByRole(),
- getAllCompanies(),
- getAllRoles()
- ])
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Vendor Admin User Management</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다.
- </p>
- </div>
- <Separator />
- <AdmUserTable promises={promises} />
- </div>
- </React.Suspense>
-
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/system/layout.tsx b/app/[lng]/procurement/(procurement)/system/layout.tsx
deleted file mode 100644
index 2776ed8b..00000000
--- a/app/[lng]/procurement/(procurement)/system/layout.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-
-export const metadata: Metadata = {
- title: "System Setting",
- // description: "Advanced form example using react-hook-form and Zod.",
-}
-
-
-interface SettingsLayoutProps {
- children: React.ReactNode
- params: { lng: string }
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string }
-}) {
- const resolvedParams = await params
- const lng = resolvedParams.lng
-
-
- const sidebarNavItems = [
-
- {
- title: "삼성중공업 사용자",
- href: `/${lng}/evcp/system`,
- },
- {
- title: "Roles",
- href: `/${lng}/evcp/system/roles`,
- },
- {
- title: "권한 통제",
- href: `/${lng}/evcp/system/permissions`,
- },
- {
- title: "협력업체 사용자",
- href: `/${lng}/evcp/system/admin-users`,
- },
-
- {
- title: "비밀번호 정책",
- href: `/${lng}/evcp/system/password-policy`,
- },
-
- ]
-
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
- {/* <p className="text-muted-foreground">
- 사용자, 롤, 접근 권한을 관리하세요.
- </p> */}
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 ">{children}</div>
- </div>
- </div>
- </section>
- </div>
-
-
- </>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/system/page.tsx b/app/[lng]/procurement/(procurement)/system/page.tsx
deleted file mode 100644
index fe0a262c..00000000
--- a/app/[lng]/procurement/(procurement)/system/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsCache } from "@/lib/admin-users/validations"
-import { getAllRoles, getUsersEVCP } from "@/lib/users/service"
-import { getUserCountGroupByRole } from "@/lib/admin-users/service"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { UserTable } from "@/lib/users/table/users-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function SystemUserPage(props: IndexPageProps) {
-
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getUsersEVCP({
- ...search,
- filters: validFilters,
- }),
- getUserCountGroupByRole(),
- getAllRoles()
- ])
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "12rem", "12rem", "12rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">SHI Users</h3>
- <p className="text-sm text-muted-foreground">
- 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <UserTable promises={promises} />
- </div>
- </React.Suspense>
-
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx b/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx
deleted file mode 100644
index 0f14fefe..00000000
--- a/app/[lng]/procurement/(procurement)/system/password-policy/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-// app/admin/password-policy/page.tsx
-
-import * as React from "react"
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Separator } from "@/components/ui/separator"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { AlertTriangle } from "lucide-react"
-import SecuritySettingsTable from "@/components/system/passwordPolicy"
-import { getSecuritySettings } from "@/lib/password-policy/service"
-
-
-export default async function PasswordPolicyPage() {
- try {
- // 보안 설정 데이터 로드
- const securitySettings = await getSecuritySettings()
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={0}
- filterableColumnCount={0}
- cellWidths={["20rem", "30rem", "15rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <SecuritySettingsTable initialSettings={securitySettings} />
- </div>
- </React.Suspense>
- )
- } catch (error) {
- console.error('Failed to load security settings:', error)
-
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <Alert variant="destructive">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.
- </AlertDescription>
- </Alert>
- </div>
- )
- }
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/system/permissions/page.tsx b/app/[lng]/procurement/(procurement)/system/permissions/page.tsx
deleted file mode 100644
index 6aa2b693..00000000
--- a/app/[lng]/procurement/(procurement)/system/permissions/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import PermissionsTree from "@/components/system/permissionsTree"
-import { Separator } from "@/components/ui/separator"
-
-export default function PermissionsPage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Permissions</h3>
- <p className="text-sm text-muted-foreground">
- Set permissions to the menu by Role
- </p>
- </div>
- <Separator />
- <PermissionsTree/>
- </div>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/system/roles/page.tsx b/app/[lng]/procurement/(procurement)/system/roles/page.tsx
deleted file mode 100644
index fe074600..00000000
--- a/app/[lng]/procurement/(procurement)/system/roles/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Separator } from "@/components/ui/separator"
-
-import { searchParamsCache } from "@/lib/roles/validations"
-import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
-import { RolesTable } from "@/lib/roles/table/roles-table"
-import { getRolesWithCount } from "@/lib/roles/services"
-import { getUsersAll } from "@/lib/users/service"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function UserTable(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
- const search2 = searchParamsCache2.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRolesWithCount({
- ...search,
- filters: validFilters,
- }),
-
-
- ])
-
-
- const promises2 = Promise.all([
- getUsersAll({
- ...search2,
- filters: validFilters,
- }, "evcp"),
- ])
-
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Role Management</h3>
- <p className="text-sm text-muted-foreground">
- 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
- </p>
- </div>
- <Separator />
- <RolesTable promises={promises} promises2={promises2} />
- </div>
- </React.Suspense>
-
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/tbe/page.tsx b/app/[lng]/procurement/(procurement)/tbe/page.tsx
deleted file mode 100644
index 1a7fdf86..00000000
--- a/app/[lng]/procurement/(procurement)/tbe/page.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-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"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-
-interface IndexPageProps {
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
-}
-
-// 타입별 페이지 설명 구성 (Budgetary 제외)
-const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = {
- "purchase": {
- title: "Purchase RFQ Technical Bid Evaluation",
- description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE
- },
- "purchase-budgetary": {
- title: "Purchase Budgetary RFQ Technical Bid Evaluation",
- description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE_BUDGETARY
- }
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- // URL 쿼리 파라미터에서 타입 추출
- const searchParams = await props.searchParams
- // 기본값으로 'purchase' 사용
- const typeParam = searchParams?.type as string || 'purchase'
-
- // 유효한 타입인지 확인하고 기본값 설정
- const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase'
- const rfqType = typeConfig[validType].rfqType
-
- // SearchParams 파싱 (Zod)
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 현재 선택된 타입의 데이터 로드
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- })
- ])
-
- // 페이지 경로 생성 함수 - 단순화
- const getTabUrl = (type: string) => {
- return `/${lng}/evcp/tbe?type=${type}`;
- }
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- Technical Bid Evaluation
- </h2>
- <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- </div>
- </div>
-
- {/* 타입 선택 탭 (Budgetary 제외) */}
- <Tabs defaultValue={validType} value={validType} className="w-full">
- <TabsList className="grid grid-cols-2 w-full max-w-md">
- <TabsTrigger value="purchase" asChild>
- <a href={getTabUrl('purchase')}>Purchase</a>
- </TabsTrigger>
- <TabsTrigger value="purchase-budgetary" asChild>
- <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a>
- </TabsTrigger>
- </TabsList>
-
- <div className="mt-2">
- <p className="text-sm text-muted-foreground">
- {typeConfig[validType].description}
- </p>
- </div>
- </Tabs>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx b/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx
deleted file mode 100644
index fb80cf64..00000000
--- a/app/[lng]/procurement/(procurement)/vendor-candidates/page.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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 { 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<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCandidateCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getVendorCandidates({
- ...search,
- filters: validFilters,
- }),
- getVendorCandidateCounts()
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 발굴업체 등록 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- {/* 수집일 라벨과 DateRangePicker를 함께 배치 */}
- <div className="flex items-center justify-start gap-2">
- {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */}
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- <DateRangePicker
- triggerSize="sm"
- triggerClassName="w-56 sm:w-60"
- align="end"
- shallow={false}
- showClearButton={true}
- placeholder="수집일 날짜 범위를 고르세요"
- />
- </React.Suspense>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorCandidateTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx b/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx
deleted file mode 100644
index e6f9ce82..00000000
--- a/app/[lng]/procurement/(procurement)/vendor-check-list/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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 { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation"
-import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table"
-import { getGeneralEvaluations } from "@/lib/general-check-list/service"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = getGenralEvaluationsSchema.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getGeneralEvaluations({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가자료 문항 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <GeneralEvaluationsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx b/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx
deleted file mode 100644
index af9f3e11..00000000
--- a/app/[lng]/procurement/(procurement)/vendor-investigation/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-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 { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/investigation-table"
-import { getVendorsInvestigation } from "@/lib/vendor-investigation/service"
-import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsInvestigationCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getVendorsInvestigation({
- ...search,
- filters: validFilters,
- }),
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 실사 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다.
-
- </p> */}
- </div>
- </div>
- </div>
-
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorsInvestigationTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/vendor-type/page.tsx b/app/[lng]/procurement/(procurement)/vendor-type/page.tsx
deleted file mode 100644
index 96169e8a..00000000
--- a/app/[lng]/procurement/(procurement)/vendor-type/page.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 업체 유형
- </h2>
- {/* <p className="text-muted-foreground">
- 업체 유형을 등록하고 관리할 수 있습니다.{" "}
-
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorTypesTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx
deleted file mode 100644
index 5d5838c6..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/items/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { getVendorItems } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsItemCache } from "@/lib/vendors/validations"
-import { VendorItemsTable } from "@/lib/vendors/items-table/item-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsItemCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getVendorItems({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 공급품목(패키지)
- </h3>
- <p className="text-sm text-muted-foreground">
- {/* 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. */}
- </p>
- </div>
- <Separator />
- <div>
- <VendorItemsTable promises={promises} vendorId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx
deleted file mode 100644
index 7e2cd4f6..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/layout.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
-import { Vendor } from "@/db/schema/vendors"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import Link from "next/link"
-export const metadata: Metadata = {
- title: "Vendor Detail",
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- 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 vendor: Vendor | null = await findVendorById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "연락처",
- href: `/${lng}/evcp/vendors/${id}/info`,
- },
- {
- title: "공급품목(패키지)",
- href: `/${lng}/evcp/vendors/${id}/info/items`,
- },
- {
- title: "공급품목(자재그룹)",
- href: `/${lng}/evcp/vendors/${id}/info/materials`,
- },
- {
- title: "견적 히스토리",
- href: `/${lng}/evcp/vendors/${id}/info/rfq-history`,
- },
- {
- title: "입찰 히스토리",
- href: `/${lng}/evcp/vendors/${id}/info/bid-history`,
- },
- {
- title: "계약 히스토리",
- href: `/${lng}/evcp/vendors/${id}/info/contract-history`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/vendors`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>협력업체 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {vendor
- ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
- : "Loading Vendor..."}
- </h2>
- <p className="text-muted-foreground">협력업체 관련 상세사항을 확인하세요.</p>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx
deleted file mode 100644
index 0ebb66ba..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/materials/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsMaterialCache } from "@/lib/vendors/validations"
-import { getVendorMaterials } from "@/lib/vendors/service"
-import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsMaterialCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getVendorMaterials({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 공급품목(자재 그룹)
- </h3>
- <p className="text-sm text-muted-foreground">
- {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */}
- </p>
- </div>
- <Separator />
- <div>
- <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx
deleted file mode 100644
index 6279e924..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { getVendorContacts } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsContactCache } from "@/lib/vendors/validations"
-import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsContactCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getVendorContacts({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Contacts
- </h3>
- <p className="text-sm text-muted-foreground">
- 업무별 담당자 정보를 확인하세요.
- </p>
- </div>
- <Separator />
- <div>
- <VendorContactsTable promises={promises} vendorId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx
deleted file mode 100644
index c7f8f8b6..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/[id]/info/rfq-history/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { getRfqHistory } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqHistoryPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ History
- </h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <VendorRfqHistoryTable promises={promises} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/procurement/(procurement)/vendors/page.tsx b/app/[lng]/procurement/(procurement)/vendors/page.tsx
deleted file mode 100644
index 02616999..00000000
--- a/app/[lng]/procurement/(procurement)/vendors/page.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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/vendors/validations"
-import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
-import { VendorsTable } from "@/lib/vendors/table/vendors-table"
-import { Ellipsis } from "lucide-react"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getVendors({
- ...search,
- filters: validFilters,
- }),
- getVendorStatusCounts(),
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체에 대한 요약 정보를 확인하고{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/page.tsx b/app/[lng]/procurement/page.tsx
deleted file mode 100644
index f9662cb7..00000000
--- a/app/[lng]/procurement/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Metadata } from "next"
-import { Suspense } from "react"
-import { LoginFormSkeleton } from "@/components/login/login-form-skeleton"
-import { LoginFormSHI } from "@/components/login/login-form-shi"
-
-export const metadata: Metadata = {
- title: "eVCP Portal",
- description: "",
-}
-
-export default function AuthenticationPage() {
-
-
- return (
- <>
- <Suspense fallback={<LoginFormSkeleton/>}>
- <LoginFormSHI />
- </Suspense>
- </>
- )
-}
diff --git a/app/[lng]/sales/(sales)/bid-projects/page.tsx b/app/[lng]/sales/(sales)/bid-projects/page.tsx
deleted file mode 100644
index 38cbf91a..00000000
--- a/app/[lng]/sales/(sales)/bid-projects/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 프로젝트 관리
- </h2>
- {/* <p className="text-muted-foreground">
- SAP(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다.
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <BidProjectsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/bqcbe/page.tsx b/app/[lng]/sales/(sales)/bqcbe/page.tsx
deleted file mode 100644
index 30935645..00000000
--- a/app/[lng]/sales/(sales)/bqcbe/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
- 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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- CBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllCbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/bqtbe/page.tsx b/app/[lng]/sales/(sales)/bqtbe/page.tsx
deleted file mode 100644
index 3e56cfaa..00000000
--- a/app/[lng]/sales/(sales)/bqtbe/page.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-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<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqTBEPage(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 = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- }
- )
- ])
-
- // 4) 렌더링
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 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([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx
deleted file mode 100644
index 2b80e64f..00000000
--- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-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 { 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",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary-rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx
deleted file mode 100644
index f342bbff..00000000
--- a/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.PURCHASE_BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx
deleted file mode 100644
index b1be29db..00000000
--- a/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { searchParamsHullCache } from "@/lib/techsales-rfq/validations"
-import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-
-interface HullRfqPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function HullRfqPage(props: HullRfqPageProps) {
- // searchParams를 await하여 resolve
- const searchParams = await props.searchParams
-
- // 해양 HULL용 파라미터 파싱
- const search = searchParamsHullCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
-
- // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달
- const promises = Promise.all([
- getTechSalesHullRfqsWithJoin({
- ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
- filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
- })
- ])
-
- return (
- <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
- {/* 고정 헤더 영역 */}
- <div className="flex-shrink-0">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 기술영업-해양 Hull RFQ
- </h2>
- </div>
- </div>
- </div>
-
- {/* 테이블 영역 - 남은 공간 모두 차지 */}
- <div className="flex-1 min-h-0">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQListTable promises={promises} className="h-full" rfqType="HULL" />
- </React.Suspense>
- </div>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx
deleted file mode 100644
index b7bf9d15..00000000
--- a/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { searchParamsShipCache } from "@/lib/techsales-rfq/validations"
-import { getTechSalesShipRfqsWithJoin } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: RfqPageProps) {
- // searchParams를 await하여 resolve
- const searchParams = await props.searchParams
-
- // 조선용 파라미터 파싱
- const search = searchParamsShipCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
-
- // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달
- const promises = Promise.all([
- getTechSalesShipRfqsWithJoin({
- ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
- filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
- })
- ])
-
- return (
- <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
- {/* 고정 헤더 영역 */}
- <div className="flex-shrink-0">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 기술영업-조선 RFQ
- </h2>
- </div>
- </div>
- </div>
-
- {/* 테이블 영역 - 남은 공간 모두 차지 */}
- <div className="flex-1 min-h-0">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQListTable promises={promises} className="h-full" rfqType="SHIP" />
- </React.Suspense>
- </div>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx
deleted file mode 100644
index f84a9794..00000000
--- a/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { searchParamsTopCache } from "@/lib/techsales-rfq/validations"
-import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-
-interface HullRfqPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function HullRfqPage(props: HullRfqPageProps) {
- // searchParams를 await하여 resolve
- const searchParams = await props.searchParams
-
- // 해양 TOP용 파라미터 파싱
- const search = searchParamsTopCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
-
- // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달
- const promises = Promise.all([
- getTechSalesTopRfqsWithJoin({
- ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
- filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
- })
- ])
-
- return (
- <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
- {/* 고정 헤더 영역 */}
- <div className="flex-shrink-0">
- <div className="flex items-center justify-between">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 기술영업-해양 TOP RFQ
- </h2>
- </div>
- </div>
- </div>
-
- {/* 테이블 영역 - 남은 공간 모두 차지 */}
- <div className="flex-1 min-h-0">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQListTable promises={promises} className="h-full" rfqType="TOP" />
- </React.Suspense>
- </div>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 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([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx
deleted file mode 100644
index d58d8363..00000000
--- a/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-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 { 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",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- 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: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary Quote 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
-
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/page.tsx b/app/[lng]/sales/(sales)/budgetary/page.tsx
deleted file mode 100644
index 15b4cdd4..00000000
--- a/app/[lng]/sales/(sales)/budgetary/page.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/dashboard/page.tsx b/app/[lng]/sales/(sales)/dashboard/page.tsx
deleted file mode 100644
index 1d61dc16..00000000
--- a/app/[lng]/sales/(sales)/dashboard/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-// app/invalid-access/page.tsx
-
-export default function InvalidAccessPage() {
- return (
- <main style={{ padding: '40px', textAlign: 'center' }}>
- <h1>부적절한 접근입니다</h1>
- <p>
- 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br />
- SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다.
- </p>
- <p>
- <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong>
- </p>
- </main>
- );
- }
- \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/esg-check-list/page.tsx b/app/[lng]/sales/(sales)/esg-check-list/page.tsx
deleted file mode 100644
index dd97c74c..00000000
--- a/app/[lng]/sales/(sales)/esg-check-list/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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 { getEsgEvaluations } from "@/lib/esg-check-list/service"
-import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation"
-import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = getEsgEvaluationsSchema.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getEsgEvaluations({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- ESG 자가진단평가 문항 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <EsgEvaluationsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx
deleted file mode 100644
index 34409524..00000000
--- a/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-/* IMPORT */
-import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
-import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
-import { getValidFilters } from '@/lib/data-table';
-import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
-import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
-import { Shell } from '@/components/shell';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Suspense } from 'react';
-import { type SearchParams } from '@/types/table';
-
-// ----------------------------------------------------------------------------------------------------
-
-/* TYPES */
-interface EvaluationCriteriaPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* REGULAR EVALUATION CRITERIA PAGE */
-async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
- const promises = Promise.all([
- getRegEvalCriteria({
- ...search,
- filters: validFilters,
- }),
- ]);
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가기준표 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 협력업체 평가에 사용되는 평가기준표를 관리{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </Suspense>
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={11}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RegEvalCriteriaTable promises={promises} />
- </Suspense>
- </Shell>
- )
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* EXPORT */
-export default EvaluationCriteriaPage; \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx
deleted file mode 100644
index 56b8ecef..00000000
--- a/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { HelpCircle } from "lucide-react"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-
-import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation"
-import { getEvaluationTargets } from "@/lib/evaluation-target-list/service"
-import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table"
-
-export const metadata: Metadata = {
- title: "협력업체 평가 대상 관리",
- description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.",
-}
-
-interface EvaluationTargetsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-
-
-export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsEvaluationTargetsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // 현재 평가년도 (필터에서 가져오거나 기본값 사용)
- const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getEvaluationTargets({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- {/* 간소화된 헤더 */}
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가 대상 관리
- </h2>
- <Badge variant="outline" className="text-sm">
- {currentEvaluationYear}년도
- </Badge>
-
- </div>
- </div>
- </div>
-
- {/* 메인 테이블 (통계는 테이블 내부로 이동) */}
- <React.Suspense
- key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={6}
- cellWidths={[
- "3rem", // checkbox
- "5rem", // 평가년도
- "4rem", // 구분
- "8rem", // 벤더코드
- "12rem", // 벤더명
- "4rem", // 내외자
- "6rem", // 자재구분
- "5rem", // 상태
- "5rem", // 의견일치
- "8rem", // 담당자현황
- "10rem", // 관리자의견
- "8rem" // actions
- ]}
- shrinkZero
- />
- }
- >
- {currentEvaluationYear &&
- <EvaluationTargetsTable
- promises={promises}
- evaluationYear={currentEvaluationYear}
- />
-}
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/evaluation/page.tsx b/app/[lng]/sales/(sales)/evaluation/page.tsx
deleted file mode 100644
index 2d8cbed7..00000000
--- a/app/[lng]/sales/(sales)/evaluation/page.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-// ================================================================
-// 4. PERIODIC EVALUATIONS PAGE
-// ================================================================
-
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { HelpCircle } from "lucide-react"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table"
-import { getPeriodicEvaluations } from "@/lib/evaluation/service"
-import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation"
-
-export const metadata: Metadata = {
- title: "협력업체 정기평가",
- description: "협력업체 정기평가 진행 현황을 관리합니다.",
-}
-
-interface PeriodicEvaluationsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// 프로세스 안내 팝오버 컴포넌트
-function ProcessGuidePopover() {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon" className="h-6 w-6">
- <HelpCircle className="h-4 w-4 text-muted-foreground" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-96" align="start">
- <div className="space-y-3">
- <div className="space-y-1">
- <h4 className="font-medium">정기평가 프로세스</h4>
- {/* <p className="text-sm text-muted-foreground">
- 확정된 평가 대상 업체들에 대한 정기평가 절차입니다.
- </p> */}
- </div>
- <div className="space-y-3 text-sm">
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 1
- </div>
- <div>
- <p className="font-medium">평가 대상 확정</p>
- <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 2
- </div>
- <div>
- <p className="font-medium">업체 자료 제출</p>
- <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 3
- </div>
- <div>
- <p className="font-medium">평가자 검토</p>
- <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 4
- </div>
- <div>
- <p className="font-medium">최종 확정</p>
- <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p>
- </div>
- </div>
- </div>
- </div>
- </PopoverContent>
- </Popover>
- )
-}
-
-// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함
-function getDefaultEvaluationYear() {
- return new Date().getFullYear()
-}
-
-
-
-export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsEvaluationsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters || [])
-
- // 기본 필터 처리
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // 현재 평가년도
- const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getPeriodicEvaluations({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- return (
- <Shell className="gap-4">
- {/* 헤더 */}
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가
- </h2>
- <Badge variant="outline" className="text-sm">
- {currentEvaluationYear}년도
- </Badge>
- </div>
- </div>
- </div>
-
- {/* 메인 테이블 */}
- <React.Suspense
- key={JSON.stringify(searchParams)}
- fallback={
- <DataTableSkeleton
- columnCount={15}
- searchableColumnCount={2}
- filterableColumnCount={8}
- cellWidths={[
- "3rem", // checkbox
- "5rem", // 평가년도
- "5rem", // 평가기간
- "4rem", // 구분
- "8rem", // 벤더코드
- "12rem", // 벤더명
- "4rem", // 내외자
- "6rem", // 자재구분
- "5rem", // 문서제출
- "4rem", // 제출일
- "4rem", // 마감일
- "4rem", // 총점
- "4rem", // 등급
- "5rem", // 진행상태
- "8rem" // actions
- ]}
- shrinkZero
- />
- }
- >
- <PeriodicEvaluationsTable
- promises={promises}
- evaluationYear={currentEvaluationYear}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/manage/actions.ts b/app/[lng]/sales/(sales)/faq/manage/actions.ts
deleted file mode 100644
index bc443a8a..00000000
--- a/app/[lng]/sales/(sales)/faq/manage/actions.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-'use server';
-
-import { promises as fs } from 'fs';
-import path from 'path';
-import { FaqCategory } from '@/components/faq/FaqCard';
-import { fallbackLng } from '@/i18n/settings';
-
-const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
-
-export async function updateFaqData(lng: string, newData: FaqCategory[]) {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- const updatedData = {
- ...allData,
- [lng]: newData
- };
-
- const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
- await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
-
- return { success: true };
- } catch (error) {
- console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
- return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
- }
-}
-
-export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
- try {
- const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
- const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
- if (!dataMatch) {
- throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
- }
-
- const allData = eval(`(${dataMatch[1]})`);
- return { data: allData[lng] || allData[fallbackLng] || [] };
- } catch (error) {
- console.error('FAQ 데이터 읽기 중 오류 발생:', error);
- return { data: [] };
- }
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/manage/page.tsx b/app/[lng]/sales/(sales)/faq/manage/page.tsx
deleted file mode 100644
index 011bbfa4..00000000
--- a/app/[lng]/sales/(sales)/faq/manage/page.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { FaqManager } from '@/components/faq/FaqManager';
-import { getFaqData, updateFaqData } from './actions';
-import { revalidatePath } from 'next/cache';
-import { FaqCategory } from '@/components/faq/FaqCard';
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqManagePage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const { data } = await getFaqData(lng);
-
- async function handleSave(newData: FaqCategory[]) {
- 'use server';
- await updateFaqData(lng, newData);
- revalidatePath(`/${lng}/evcp/faq`);
- }
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
- <p className="text-muted-foreground">
- Manage FAQ categories and items for {lng.toUpperCase()} language.
- </p>
- </div>
- <FaqManager initialData={data} onSave={handleSave} lng={lng} />
- </div>
- </section>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/page.tsx b/app/[lng]/sales/(sales)/faq/page.tsx
deleted file mode 100644
index 00956591..00000000
--- a/app/[lng]/sales/(sales)/faq/page.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { faqCategories } from "@/config/faqDataConfig"
-import { FaqCard } from "@/components/faq/FaqCard"
-import { Button } from "@/components/ui/button"
-import { Settings } from "lucide-react"
-import Link from "next/link"
-import { fallbackLng } from "@/i18n/settings"
-
-interface Props {
- params: {
- lng: string;
- }
-}
-
-export default async function FaqPage(props: Props) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
-
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="space-y-6 p-10 pb-16">
- <div className="flex justify-between items-center">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">FAQ</h2>
- {/* <p className="text-muted-foreground">
- Find answers to common questions about using the EVCP system.
- </p> */}
- </div>
- <Link href={`/${lng}/evcp/faq/manage`}>
- <Button variant="outline">
- <Settings className="w-4 h-4 mr-2" />
- FAQ 관리
- </Button>
- </Link>
- </div>
- <Separator className="my-6" />
-
- <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
- <TabsList>
- {localizedFaqCategories.map((category) => (
- <TabsTrigger key={category.label} value={category.label}>
- {category.label}
- </TabsTrigger>
- ))}
- </TabsList>
-
- {localizedFaqCategories.map((category) => (
- <TabsContent key={category.label} value={category.label} className="space-y-4">
- {category.items.map((item, index) => (
- <FaqCard key={index} item={item} />
- ))}
- </TabsContent>
- ))}
- </Tabs>
- </div>
- </section>
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items-tech/layout.tsx b/app/[lng]/sales/(sales)/items-tech/layout.tsx
deleted file mode 100644
index d375059b..00000000
--- a/app/[lng]/sales/(sales)/items-tech/layout.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items-tech/page.tsx b/app/[lng]/sales/(sales)/items-tech/page.tsx
deleted file mode 100644
index 55ac9c63..00000000
--- a/app/[lng]/sales/(sales)/items-tech/page.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
- const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
- const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
- const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
- const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
- const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
-
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...shipbuildingSearch,
- filters: validShipbuildingFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...offshoreTopSearch,
- filters: validOffshoreTopFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...offshoreHullSearch,
- filters: validOffshoreHullFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/items/page.tsx b/app/[lng]/sales/(sales)/items/page.tsx
deleted file mode 100644
index f8d9a5b1..00000000
--- a/app/[lng]/sales/(sales)/items/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-// app/items/page.tsx (업데이트)
-import * as React from "react"
-import { type SearchParams } from "@/types/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/items/validations"
-import { getItems } from "@/lib/items/service"
-import { ItemsTable } from "@/lib/items/table/items-table"
-import { ViewModeToggle } from "@/components/data-table/view-mode-toggle"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- // pageSize 기반으로 모드 자동 결정
- const isInfiniteMode = search.perPage >= 1_000_000
-
- // 페이지네이션 모드일 때만 서버에서 데이터 가져오기
- // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드
- const promises = isInfiniteMode
- ? undefined
- : Promise.all([
- getItems(search), // searchParamsCache의 결과를 그대로 사용
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 패키지 넘버
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다.
- </p> */}
- </div>
- </div>
-
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* DateRangePicker 등 추가 컴포넌트 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- {/* 통합된 ItemsTable 컴포넌트 사용 */}
- <ItemsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/layout.tsx b/app/[lng]/sales/(sales)/layout.tsx
deleted file mode 100644
index 82b53307..00000000
--- a/app/[lng]/sales/(sales)/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
-import { SiteFooter } from '@/components/layout/Footer';
-
-export default function EvcpLayout({ children }: { children: ReactNode }) {
- return (
- <div className="relative flex min-h-svh flex-col bg-background">
- {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */}
- <Header />
- <main className="flex flex-1 flex-col">
- <div className='container-wrapper'>
- {children}
- </div>
- </main>
- <SiteFooter/>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/project-gtc/page.tsx b/app/[lng]/sales/(sales)/project-gtc/page.tsx
deleted file mode 100644
index d5cb467a..00000000
--- a/app/[lng]/sales/(sales)/project-gtc/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getProjectGtcList } from "@/lib/project-gtc/service"
-import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations"
-import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = projectGtcSearchParamsSchema.parse(searchParams)
-
- const promises = Promise.all([
- getProjectGtcList({
- page: search.page,
- perPage: search.perPage,
- search: search.search,
- sort: search.sort,
- }),
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 GTC 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다.
- 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* 추가 기능이 필요하면 여기에 추가 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]}
- shrinkZero
- />
- }
- >
- <ProjectGtcTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/project-vendors/page.tsx b/app/[lng]/sales/(sales)/project-vendors/page.tsx
deleted file mode 100644
index 525cff07..00000000
--- a/app/[lng]/sales/(sales)/project-vendors/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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<SearchParams>
-}
-
-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 (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 AVL 리스트
- </h2>
- {/* <p className="text-muted-foreground">
- 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ProjectAVLTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/projects/page.tsx b/app/[lng]/sales/(sales)/projects/page.tsx
deleted file mode 100644
index 649dd56f..00000000
--- a/app/[lng]/sales/(sales)/projects/page.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-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 { ItemsTable } from "@/lib/items/table/items-table"
-import { getProjectLists } from "@/lib/projects/service"
-import { ProjectsTable } from "@/lib/projects/table/projects-table"
-import { searchParamsProjectsCache } from "@/lib/projects/validation"
-
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsProjectsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getProjectLists({
- ...search,
- filters: validFilters,
- }),
-
- ])
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 수행 프로젝트 리스트 from S-EDP
- </h2>
- {/* <p className="text-muted-foreground">
- S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ProjectsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/report/page.tsx b/app/[lng]/sales/(sales)/report/page.tsx
deleted file mode 100644
index 152721cf..00000000
--- a/app/[lng]/sales/(sales)/report/page.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import * as React from "react";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Shell } from "@/components/shell";
-import { ErrorBoundary } from "@/components/error-boundary";
-import { getDashboardData } from "@/lib/dashboard/service";
-import { DashboardClient } from "@/lib/dashboard/dashboard-client";
-
-// 데이터 fetch 시 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생.
-// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리
-// getDashboardData 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨.
-export const dynamic = 'force-dynamic'
-
-export default async function IndexPage() {
- // domain을 명시적으로 전달
- const domain = "sales";
-
- try {
- // 서버에서 직접 데이터 fetch
- const dashboardData = await getDashboardData(domain);
-
- return (
- <Shell className="gap-2">
- <DashboardClient initialData={dashboardData} />
- </Shell>
- );
- } catch (error) {
- console.error("Dashboard data fetch error:", error);
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-center py-12">
- <div className="text-center space-y-2">
- <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p>
- <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p>
- </div>
- </div>
- </Shell>
- );
- }
-}
-
-function DashboardSkeleton() {
- return (
- <div className="space-y-6">
- {/* 헤더 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="space-y-2">
- <Skeleton className="h-8 w-48" />
- <Skeleton className="h-4 w-72" />
- </div>
- <Skeleton className="h-10 w-24" />
- </div>
-
- {/* 요약 카드 스켈레톤 */}
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
- {[...Array(4)].map((_, i) => (
- <div key={i} className="space-y-3 p-6 border rounded-lg">
- <div className="flex items-center justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-4" />
- </div>
- <Skeleton className="h-8 w-12" />
- <Skeleton className="h-3 w-20" />
- </div>
- ))}
- </div>
-
- {/* 차트 스켈레톤 */}
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
- {[...Array(2)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <div className="space-y-2">
- <Skeleton className="h-6 w-32" />
- <Skeleton className="h-4 w-48" />
- </div>
- <Skeleton className="h-[300px] w-full" />
- </div>
- ))}
- </div>
-
- {/* 탭 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-10 w-64" />
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
- {[...Array(6)].map((_, i) => (
- <div key={i} className="space-y-4 p-6 border rounded-lg">
- <Skeleton className="h-6 w-32" />
- <div className="space-y-3">
- <div className="flex justify-between">
- <Skeleton className="h-4 w-16" />
- <Skeleton className="h-4 w-12" />
- </div>
- <div className="flex gap-2">
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- <Skeleton className="h-6 w-16" />
- </div>
- <Skeleton className="h-2 w-full" />
- </div>
- </div>
- ))}
- </div>
- </div>
- </div>
- );
-}
diff --git a/app/[lng]/sales/(sales)/settings/layout.tsx b/app/[lng]/sales/(sales)/settings/layout.tsx
deleted file mode 100644
index 6c380919..00000000
--- a/app/[lng]/sales/(sales)/settings/layout.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-
-export const metadata: Metadata = {
- title: "Settings",
- // description: "Advanced form example using react-hook-form and Zod.",
-}
-
-
-interface SettingsLayoutProps {
- children: React.ReactNode
- params: { lng: string }
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string }
-}) {
- const resolvedParams = await params
- const lng = resolvedParams.lng
-
-
- const sidebarNavItems = [
-
- {
- title: "Account",
- href: `/${lng}/evcp/settings`,
- },
- {
- title: "Preferences",
- href: `/${lng}/evcp/settings/preferences`,
- }
-
-
- ]
-
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">설정</h2>
- {/* <p className="text-muted-foreground">
- Manage your account settings and preferences.
- </p> */}
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 ">{children}</div>
- </div>
- </div>
- </section>
- </div>
-
-
- </>
- )
-}
diff --git a/app/[lng]/sales/(sales)/settings/page.tsx b/app/[lng]/sales/(sales)/settings/page.tsx
deleted file mode 100644
index eba5e948..00000000
--- a/app/[lng]/sales/(sales)/settings/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AccountForm } from "@/components/settings/account-form"
-
-export default function SettingsAccountPage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Account</h3>
- {/* <p className="text-sm text-muted-foreground">
- Update your account settings. Set your preferred language and
- timezone.
- </p> */}
- </div>
- <Separator />
- <AccountForm />
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/settings/preferences/page.tsx b/app/[lng]/sales/(sales)/settings/preferences/page.tsx
deleted file mode 100644
index e2a88021..00000000
--- a/app/[lng]/sales/(sales)/settings/preferences/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AppearanceForm } from "@/components/settings/appearance-form"
-
-export default function SettingsAppearancePage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Preference</h3>
- <p className="text-sm text-muted-foreground">
- Customize the preference of the app.
- </p>
- </div>
- <Separator />
- <AppearanceForm />
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/system/admin-users/page.tsx b/app/[lng]/sales/(sales)/system/admin-users/page.tsx
deleted file mode 100644
index 11a9e9fb..00000000
--- a/app/[lng]/sales/(sales)/system/admin-users/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-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 { DateRangePicker } from "@/components/date-range-picker"
-import { Separator } from "@/components/ui/separator"
-
-import { searchParamsCache } from "@/lib/admin-users/validations"
-import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service"
-import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function UserTable(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getUsers({
- ...search,
- filters: validFilters,
- }),
- getUserCountGroupByCompany(),
- getUserCountGroupByRole(),
- getAllCompanies(),
- getAllRoles()
- ])
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Vendor Admin User Management</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다.
- </p>
- </div>
- <Separator />
- <AdmUserTable promises={promises} />
- </div>
- </React.Suspense>
-
- )
-}
diff --git a/app/[lng]/sales/(sales)/system/layout.tsx b/app/[lng]/sales/(sales)/system/layout.tsx
deleted file mode 100644
index 2776ed8b..00000000
--- a/app/[lng]/sales/(sales)/system/layout.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-
-export const metadata: Metadata = {
- title: "System Setting",
- // description: "Advanced form example using react-hook-form and Zod.",
-}
-
-
-interface SettingsLayoutProps {
- children: React.ReactNode
- params: { lng: string }
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string }
-}) {
- const resolvedParams = await params
- const lng = resolvedParams.lng
-
-
- const sidebarNavItems = [
-
- {
- title: "삼성중공업 사용자",
- href: `/${lng}/evcp/system`,
- },
- {
- title: "Roles",
- href: `/${lng}/evcp/system/roles`,
- },
- {
- title: "권한 통제",
- href: `/${lng}/evcp/system/permissions`,
- },
- {
- title: "협력업체 사용자",
- href: `/${lng}/evcp/system/admin-users`,
- },
-
- {
- title: "비밀번호 정책",
- href: `/${lng}/evcp/system/password-policy`,
- },
-
- ]
-
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="space-y-0.5">
- <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
- {/* <p className="text-muted-foreground">
- 사용자, 롤, 접근 권한을 관리하세요.
- </p> */}
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 ">{children}</div>
- </div>
- </div>
- </section>
- </div>
-
-
- </>
- )
-}
diff --git a/app/[lng]/sales/(sales)/system/page.tsx b/app/[lng]/sales/(sales)/system/page.tsx
deleted file mode 100644
index fe0a262c..00000000
--- a/app/[lng]/sales/(sales)/system/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsCache } from "@/lib/admin-users/validations"
-import { getAllRoles, getUsersEVCP } from "@/lib/users/service"
-import { getUserCountGroupByRole } from "@/lib/admin-users/service"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { UserTable } from "@/lib/users/table/users-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function SystemUserPage(props: IndexPageProps) {
-
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getUsersEVCP({
- ...search,
- filters: validFilters,
- }),
- getUserCountGroupByRole(),
- getAllRoles()
- ])
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "12rem", "12rem", "12rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">SHI Users</h3>
- <p className="text-sm text-muted-foreground">
- 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <UserTable promises={promises} />
- </div>
- </React.Suspense>
-
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/system/password-policy/page.tsx b/app/[lng]/sales/(sales)/system/password-policy/page.tsx
deleted file mode 100644
index 0f14fefe..00000000
--- a/app/[lng]/sales/(sales)/system/password-policy/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-// app/admin/password-policy/page.tsx
-
-import * as React from "react"
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Separator } from "@/components/ui/separator"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { AlertTriangle } from "lucide-react"
-import SecuritySettingsTable from "@/components/system/passwordPolicy"
-import { getSecuritySettings } from "@/lib/password-policy/service"
-
-
-export default async function PasswordPolicyPage() {
- try {
- // 보안 설정 데이터 로드
- const securitySettings = await getSecuritySettings()
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={4}
- searchableColumnCount={0}
- filterableColumnCount={0}
- cellWidths={["20rem", "30rem", "15rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <SecuritySettingsTable initialSettings={securitySettings} />
- </div>
- </React.Suspense>
- )
- } catch (error) {
- console.error('Failed to load security settings:', error)
-
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
- <p className="text-sm text-muted-foreground">
- 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
- </p>
- </div>
- <Separator />
- <Alert variant="destructive">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.
- </AlertDescription>
- </Alert>
- </div>
- )
- }
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/system/permissions/page.tsx b/app/[lng]/sales/(sales)/system/permissions/page.tsx
deleted file mode 100644
index 6aa2b693..00000000
--- a/app/[lng]/sales/(sales)/system/permissions/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import PermissionsTree from "@/components/system/permissionsTree"
-import { Separator } from "@/components/ui/separator"
-
-export default function PermissionsPage() {
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Permissions</h3>
- <p className="text-sm text-muted-foreground">
- Set permissions to the menu by Role
- </p>
- </div>
- <Separator />
- <PermissionsTree/>
- </div>
- )
-}
diff --git a/app/[lng]/sales/(sales)/system/roles/page.tsx b/app/[lng]/sales/(sales)/system/roles/page.tsx
deleted file mode 100644
index fe074600..00000000
--- a/app/[lng]/sales/(sales)/system/roles/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Separator } from "@/components/ui/separator"
-
-import { searchParamsCache } from "@/lib/roles/validations"
-import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
-import { RolesTable } from "@/lib/roles/table/roles-table"
-import { getRolesWithCount } from "@/lib/roles/services"
-import { getUsersAll } from "@/lib/users/service"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function UserTable(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
- const search2 = searchParamsCache2.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRolesWithCount({
- ...search,
- filters: validFilters,
- }),
-
-
- ])
-
-
- const promises2 = Promise.all([
- getUsersAll({
- ...search2,
- filters: validFilters,
- }, "evcp"),
- ])
-
-
- return (
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">Role Management</h3>
- <p className="text-sm text-muted-foreground">
- 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
- </p>
- </div>
- <Separator />
- <RolesTable promises={promises} promises2={promises2} />
- </div>
- </React.Suspense>
-
- )
-}
diff --git a/app/[lng]/sales/(sales)/tbe/page.tsx b/app/[lng]/sales/(sales)/tbe/page.tsx
deleted file mode 100644
index 211cf376..00000000
--- a/app/[lng]/sales/(sales)/tbe/page.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-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"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-
-interface IndexPageProps {
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
-}
-
-// 타입별 페이지 설명 구성 (Budgetary 제외)
-const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = {
- "purchase": {
- title: "Purchase RFQ Technical Bid Evaluation",
- description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE
- },
- "purchase-budgetary": {
- title: "Purchase Budgetary RFQ Technical Bid Evaluation",
- description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE_BUDGETARY
- }
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- // URL 쿼리 파라미터에서 타입 추출
- const searchParams = await props.searchParams
- // 기본값으로 'purchase' 사용
- const typeParam = searchParams?.type as string || 'purchase'
-
- // 유효한 타입인지 확인하고 기본값 설정
- const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase'
- const rfqType = typeConfig[validType].rfqType
-
- // SearchParams 파싱 (Zod)
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 현재 선택된 타입의 데이터 로드
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- })
- ])
-
- // 페이지 경로 생성 함수 - 단순화
- const getTabUrl = (type: string) => {
- return `/${lng}/evcp/tbe?type=${type}`;
- }
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- {/* 타입 선택 탭 (Budgetary 제외) */}
- <Tabs defaultValue={validType} value={validType} className="w-full">
- <TabsList className="grid grid-cols-2 w-full max-w-md">
- <TabsTrigger value="purchase" asChild>
- <a href={getTabUrl('purchase')}>Purchase</a>
- </TabsTrigger>
- <TabsTrigger value="purchase-budgetary" asChild>
- <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a>
- </TabsTrigger>
- </TabsList>
-
- <div className="mt-2">
- <p className="text-sm text-muted-foreground">
- {typeConfig[validType].description}
- </p>
- </div>
- </Tabs>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx b/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx
deleted file mode 100644
index 5bc36790..00000000
--- a/app/[lng]/sales/(sales)/tech-contact-possible-items/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Suspense } from "react"
-import { SearchParams } from "@/types/table"
-import { Shell } from "@/components/shell"
-import { ContactPossibleItemsTable } from "@/lib/contact-possible-items/table/contact-possible-items-table"
-import { getContactPossibleItems } from "@/lib/contact-possible-items/service"
-import { searchParamsCache } from "@/lib/contact-possible-items/validations"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-
-interface ContactPossibleItemsPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function ContactPossibleItemsPage({
- searchParams,
-}: ContactPossibleItemsPageProps) {
- const resolvedSearchParams = await searchParams
- const search = searchParamsCache.parse(resolvedSearchParams)
-
- const contactPossibleItemsPromise = getContactPossibleItems(search)
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 담당자별 자재 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 기술영업 담당자별 자재를 관리합니다.
- </p> */}
- </div>
- </div>
- </div>
-
-
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "10rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ContactPossibleItemsTable
- contactPossibleItemsPromise={contactPossibleItemsPromise}
- />
- </Suspense>
-
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-project-avl/page.tsx b/app/[lng]/sales/(sales)/tech-project-avl/page.tsx
deleted file mode 100644
index 4ce018cd..00000000
--- a/app/[lng]/sales/(sales)/tech-project-avl/page.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import * as React from "react"
-import { redirect } from "next/navigation"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { SearchParams } from "@/types/table"
-import { searchParamsCache } from "@/lib/tech-project-avl/validations"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Shell } from "@/components/shell"
-import { AcceptedQuotationsTable } from "@/lib/tech-project-avl/table/accepted-quotations-table"
-import { getAcceptedTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Ellipsis } from "lucide-react"
-import { InformationButton } from "@/components/information/information-button"
-export interface PageProps {
- params: Promise<{ lng: string }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function AcceptedQuotationsPage({
- params,
- searchParams,
-}: PageProps) {
- const { lng } = await params
-
- const session = await getServerSession(authOptions)
- if (!session) {
- redirect(`/${lng}/auth/signin`)
- }
-
- const search = await searchParams
- const { page, perPage, sort, filters, search: searchText } = searchParamsCache.parse(search)
- const validFilters = getValidFilters(filters ?? [])
-
- const { data, pageCount } = await getAcceptedTechSalesVendorQuotations({
- page,
- perPage: perPage ?? 10,
- sort,
- search: searchText,
- filters: validFilters,
- })
-
- return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 Result 전송
- </h2>
- <InformationButton pagePath="evcp/tech-project-avl" />
- </div>
- {/* <p className="text-muted-foreground">
- 기술영업 승인 견적서에 대한 요약 정보를 확인하고{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 RFQ 코드, 설명, 업체명, 업체 코드 등의 상세 정보를 확인할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* Date range picker can be added here if needed */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={4}
- cellWidths={["10rem", "15rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem", "10rem", "8rem", "10rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <AcceptedQuotationsTable
- data={data}
- pageCount={pageCount}
- />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
deleted file mode 100644
index 291cd630..00000000
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { findTechVendorById } from "@/lib/tech-vendors/service"
-import { TechVendor } from "@/db/schema/techVendors"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import Link from "next/link"
-export const metadata: Metadata = {
- title: "Tech Vendor Detail",
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- 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 vendor: TechVendor | null = await findTechVendorById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "연락처",
- href: `/${lng}/evcp/tech-vendors/${id}/info`,
- },
- {
- title: "RFQ 히스토리",
- href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
- },
- {
- title: "자재 리스트",
- href: `/${lng}/evcp/tech-vendors/${id}/info/possible-items`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/tech-vendors`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>기술영업 벤더 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {vendor
- ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
- : "Loading Vendor..."}
- </h2>
- <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
deleted file mode 100644
index 9969a801..00000000
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { getTechVendorContacts } from "@/lib/tech-vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsContactCache } from "@/lib/tech-vendors/validations"
-import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsContactCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getTechVendorContacts({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Contacts
- </h3>
- <p className="text-sm text-muted-foreground">
- 업무별 담당자 정보를 확인하세요.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx
deleted file mode 100644
index 642c6e32..00000000
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { getTechVendorPossibleItems } from "@/lib/tech-vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsPossibleItemsCache } from "@/lib/tech-vendors/validations"
-import { TechVendorPossibleItemsTable } from "@/lib/tech-vendors/possible-items/possible-items-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: Promise<{
- lng: string
- id: string
- }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function TechVendorPossibleItemsPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- console.log(idAsNumber)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 possible items 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsPossibleItemsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTechVendorPossibleItems({
- ...search,
- filters: validFilters,
- }, idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 공급가능 아이템 목록
- </h3>
- <p className="text-sm text-muted-foreground">
- 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorPossibleItemsTable promises={promises} vendorId={idAsNumber} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
deleted file mode 100644
index 9122d524..00000000
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table"
-import { getTechVendorRfqHistory } from "@/lib/tech-vendors/service"
-import { searchParamsRfqHistoryCache } from "@/lib/tech-vendors/validations"
-import { Separator } from "@/components/ui/separator"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function SettingsAccountPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
-
-
- const promises = Promise.all([
- getTechVendorRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ 히스토리
- </h3>
- <p className="text-sm text-muted-foreground">
- 벤더가 참여한 기술영업 RFQ 목록입니다.
- </p>
- </div>
- <Separator />
- <div>
- <TechVendorRfqHistoryTable promises={promises} />
- </div>
- </div>
-
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/page.tsx
deleted file mode 100644
index e49ba79e..00000000
--- a/app/[lng]/sales/(sales)/tech-vendors/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-
-import { searchParamsCache } from "@/lib/tech-vendors/validations"
-import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
-import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-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([
- getTechVendors({
- ...search,
- filters: validFilters,
- }),
- getTechVendorStatusCounts(),
- ])
-
- return (
- <Shell className="gap-4">
- <div className="flex items-center justify-between">
- {/* 왼쪽: 타이틀 & 설명 */}
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 협력업체 관리</h2>
- {/* InformationButton은 필요시 추가 */}
- {/* <InformationButton pagePath="evcp/tech-vendors" /> */}
- </div>
- {/* <p className="text-muted-foreground">
- 기술영업 벤더에 대한 요약 정보를 확인하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendor-candidates/page.tsx b/app/[lng]/sales/(sales)/vendor-candidates/page.tsx
deleted file mode 100644
index f4bee95b..00000000
--- a/app/[lng]/sales/(sales)/vendor-candidates/page.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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 { 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<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCandidateCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getVendorCandidates({
- ...search,
- filters: validFilters,
- }),
- getVendorCandidateCounts()
- ])
-
- return (
- <Shell className="gap-2">
-
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <h2 className="text-2xl font-bold tracking-tight">
- 발굴업체 등록 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- {/* 수집일 라벨과 DateRangePicker를 함께 배치 */}
- <div className="flex items-center justify-start gap-2">
- {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */}
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- <DateRangePicker
- triggerSize="sm"
- triggerClassName="w-56 sm:w-60"
- align="end"
- shallow={false}
- showClearButton={true}
- placeholder="수집일 날짜 범위를 고르세요"
- />
- </React.Suspense>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <VendorCandidateTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/sales/page.tsx b/app/[lng]/sales/page.tsx
deleted file mode 100644
index f9662cb7..00000000
--- a/app/[lng]/sales/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Metadata } from "next"
-import { Suspense } from "react"
-import { LoginFormSkeleton } from "@/components/login/login-form-skeleton"
-import { LoginFormSHI } from "@/components/login/login-form-shi"
-
-export const metadata: Metadata = {
- title: "eVCP Portal",
- description: "",
-}
-
-export default function AuthenticationPage() {
-
-
- return (
- <>
- <Suspense fallback={<LoginFormSkeleton/>}>
- <LoginFormSHI />
- </Suspense>
- </>
- )
-}
diff --git a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
deleted file mode 100644
index 51430118..00000000
--- a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-// app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
-import { NextRequest, NextResponse } from "next/server"
-
-import db from '@/db/db';
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-import { procurementRfqComments, procurementRfqAttachments } from "@/db/schema"
-import { revalidateTag } from "next/cache"
-
-// 파일 저장을 위한 유틸리티
-import { writeFile, mkdir } from 'fs/promises'
-import { join } from 'path'
-import crypto from 'crypto'
-
-/**
- * 코멘트 생성 API 엔드포인트
- */
-export async function POST(
- request: NextRequest,
- { params }: { params: { rfqId: string; vendorId: string } }
-) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- return NextResponse.json(
- { success: false, message: "인증이 필요합니다" },
- { status: 401 }
- )
- }
-
- const rfqId = parseInt(params.rfqId)
- const vendorId = parseInt(params.vendorId)
-
- // 유효성 검사
- if (isNaN(rfqId) || isNaN(vendorId)) {
- return NextResponse.json(
- { success: false, message: "유효하지 않은 매개변수입니다" },
- { status: 400 }
- )
- }
-
- // FormData 파싱
- const formData = await request.formData()
- const content = formData.get("content") as string
- const isVendorComment = formData.get("isVendorComment") === "true"
- const files = formData.getAll("attachments") as File[]
-
- if (!content && files.length === 0) {
- return NextResponse.json(
- { success: false, message: "내용이나 첨부파일이 필요합니다" },
- { status: 400 }
- )
- }
-
- // 코멘트 생성
- const [comment] = await db
- .insert(procurementRfqComments)
- .values({
- rfqId,
- vendorId,
- userId: parseInt(session.user.id),
- content,
- isVendorComment,
- isRead: !isVendorComment, // 본인 메시지는 읽음 처리
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning()
-
- // 첨부파일 처리
- const attachments = []
- if (files.length > 0) {
- // 디렉토리 생성
- const uploadDir = join(process.cwd(), "public", `rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`)
- await mkdir(uploadDir, { recursive: true })
-
- // 각 파일 저장
- for (const file of files) {
- const buffer = Buffer.from(await file.arrayBuffer())
- const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
- const filePath = join(uploadDir, filename)
-
- // 파일 쓰기
- await writeFile(filePath, buffer)
-
- // DB에 첨부파일 정보 저장
- const [attachment] = await db
- .insert(procurementRfqAttachments)
- .values({
- rfqId,
- commentId: comment.id,
- fileName: file.name,
- fileSize: file.size,
- fileType: file.type,
- filePath: `/rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`,
- isVendorUpload: isVendorComment,
- uploadedBy: parseInt(session.user.id),
- vendorId,
- uploadedAt: new Date(),
- })
- .returning()
-
- attachments.push({
- id: attachment.id,
- fileName: attachment.fileName,
- fileSize: attachment.fileSize,
- fileType: attachment.fileType,
- filePath: attachment.filePath,
- uploadedAt: attachment.uploadedAt
- })
- }
- }
-
- // 캐시 무효화
- revalidateTag(`rfq-${rfqId}-comments`)
-
- // 응답 데이터 구성
- const responseData = {
- id: comment.id,
- rfqId: comment.rfqId,
- vendorId: comment.vendorId,
- userId: comment.userId,
- content: comment.content,
- isVendorComment: comment.isVendorComment,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- userName: session.user.name,
- attachments,
- isRead: comment.isRead
- }
-
- return NextResponse.json({
- success: true,
- data: { comment: responseData }
- })
- } catch (error) {
- console.error("코멘트 생성 오류:", error)
- return NextResponse.json(
- { success: false, message: "코멘트 생성 중 오류가 발생했습니다" },
- { status: 500 }
- )
- }
-} \ No newline at end of file
diff --git a/app/api/rfq-attachments/download/route.ts b/app/api/rfq-attachments/download/route.ts
deleted file mode 100644
index 5a07bc0b..00000000
--- a/app/api/rfq-attachments/download/route.ts
+++ /dev/null
@@ -1,474 +0,0 @@
-// app/api/rfq-attachments/download/route.ts
-import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants, stat } from 'fs/promises';
-import { join, normalize, resolve } from 'path';
-import db from '@/db/db';
-import { bRfqAttachmentRevisions, vendorResponseAttachmentsB } from '@/db/schema';
-import { eq } from 'drizzle-orm';
-import { getServerSession } from 'next-auth';
-import { authOptions } from '@/app/api/auth/[...nextauth]/route';
-import { createFileDownloadLog } from '@/lib/file-download-log/service';
-import rateLimit from '@/lib/rate-limit';
-import { z } from 'zod';
-import { getRequestInfo } from '@/lib/network/get-client-ip';
-
-// 허용된 파일 확장자
-const ALLOWED_EXTENSIONS = new Set([
- 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
- 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg',
- 'dwg', 'dxf', 'zip', 'rar', '7z'
-]);
-
-// 최대 파일 크기 (50MB)
-const MAX_FILE_SIZE = 50 * 1024 * 1024;
-
-// 다운로드 요청 검증 스키마
-const downloadRequestSchema = z.object({
- path: z.string().min(1, 'File path is required'),
- type: z.enum(['client', 'vendor']).optional(),
- revisionId: z.string().optional(),
- responseFileId: z.string().optional(),
-});
-
-// 파일 정보 타입
-interface FileRecord {
- id: number;
- fileName: string;
- originalFileName?: string;
- filePath: string;
- fileSize: number;
- fileType?: string;
-}
-
-// 강화된 파일 경로 검증 함수
-function validateFilePath(filePath: string): boolean {
- // null, undefined, 빈 문자열 체크
- if (!filePath || typeof filePath !== 'string') {
- return false;
- }
-
- // 위험한 패턴 체크
- const dangerousPatterns = [
- /\.\./, // 상위 디렉토리 접근
- /\/\//, // 이중 슬래시
- /[<>:"'|?*]/, // 특수문자
- /[\x00-\x1f]/, // 제어문자
- /\\+/ // 백슬래시
- ];
-
- if (dangerousPatterns.some(pattern => pattern.test(filePath))) {
- return false;
- }
-
- // 시스템 파일 접근 방지
- const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home'];
- for (const dangerousPath of dangerousPaths) {
- if (filePath.toLowerCase().startsWith(dangerousPath)) {
- return false;
- }
- }
-
- return true;
-}
-
-// 파일 확장자 검증
-function validateFileExtension(fileName: string): boolean {
- const extension = fileName.split('.').pop()?.toLowerCase() || '';
- return ALLOWED_EXTENSIONS.has(extension);
-}
-
-// 안전한 파일명 생성
-function sanitizeFileName(fileName: string): string {
- return fileName
- .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거
- .replace(/\s+/g, '_') // 공백을 언더스코어로
- .substring(0, 255); // 파일명 길이 제한
-}
-
-export async function GET(request: NextRequest) {
- const startTime = Date.now();
- const requestInfo = getRequestInfo(request);
- let fileRecord: FileRecord | null = null;
-
- try {
- // Rate limiting 체크
- const limiterResult = await rateLimit(request);
- if (!limiterResult.success) {
- console.warn('🚨 Rate limit 초과:', {
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent
- });
-
- return NextResponse.json(
- { error: "Too many requests" },
- { status: 429 }
- );
- }
-
- // 세션 확인
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- console.warn('🚨 인증되지 않은 다운로드 시도:', {
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent,
- path: request.nextUrl.searchParams.get("path")
- });
-
- return NextResponse.json(
- { error: "Unauthorized" },
- { status: 401 }
- );
- }
-
- // 파라미터 검증
- const searchParams = {
- path: request.nextUrl.searchParams.get("path"),
- type: request.nextUrl.searchParams.get("type"),
- revisionId: request.nextUrl.searchParams.get("revisionId"),
- responseFileId: request.nextUrl.searchParams.get("responseFileId"),
- };
-
- const validatedParams = downloadRequestSchema.parse(searchParams);
- const { path, type, revisionId, responseFileId } = validatedParams;
-
- // 파일 경로 보안 검증
- if (!validateFilePath(path)) {
- console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, {
- userId: session.user.id,
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent
- });
-
- return NextResponse.json(
- { error: "Invalid file path" },
- { status: 400 }
- );
- }
-
- // 경로 정규화
- const normalizedPath = normalize(path.replace(/^\/+/, ""));
-
- // DB에서 파일 정보 조회
- let dbRecord: FileRecord | null = null;
-
- if (type === "client" && revisionId) {
- // 발주처 첨부파일 리비전
- const [record] = await db
- .select({
- id: bRfqAttachmentRevisions.id,
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- })
- .from(bRfqAttachmentRevisions)
- .where(eq(bRfqAttachmentRevisions.id, Number(revisionId)));
-
- dbRecord = record;
-
- } else if (type === "vendor" && responseFileId) {
- // 벤더 응답 파일
- const [record] = await db
- .select({
- id: vendorResponseAttachmentsB.id,
- fileName: vendorResponseAttachmentsB.fileName,
- originalFileName: vendorResponseAttachmentsB.originalFileName,
- filePath: vendorResponseAttachmentsB.filePath,
- fileSize: vendorResponseAttachmentsB.fileSize,
- fileType: vendorResponseAttachmentsB.fileType,
- })
- .from(vendorResponseAttachmentsB)
- .where(eq(vendorResponseAttachmentsB.id, Number(responseFileId)));
-
- dbRecord = record;
-
- } else {
- // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색
- const [clientRecord] = await db
- .select({
- id: bRfqAttachmentRevisions.id,
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- })
- .from(bRfqAttachmentRevisions)
- .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath));
-
- if (clientRecord) {
- dbRecord = clientRecord;
- } else {
- // 벤더 파일에서도 검색
- const [vendorRecord] = await db
- .select({
- id: vendorResponseAttachmentsB.id,
- fileName: vendorResponseAttachmentsB.fileName,
- originalFileName: vendorResponseAttachmentsB.originalFileName,
- filePath: vendorResponseAttachmentsB.filePath,
- fileSize: vendorResponseAttachmentsB.fileSize,
- fileType: vendorResponseAttachmentsB.fileType,
- })
- .from(vendorResponseAttachmentsB)
- .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath));
-
- dbRecord = vendorRecord;
- }
- }
-
- // DB에서 파일 정보를 찾지 못한 경우
- if (!dbRecord) {
- console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", {
- path,
- normalizedPath,
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- return NextResponse.json(
- { error: "File not found in database" },
- { status: 404 }
- );
- }
-
- fileRecord = dbRecord;
-
- // 파일명 설정
- const fileName = dbRecord.originalFileName || dbRecord.fileName;
-
- // 파일 확장자 검증
- if (!validateFileExtension(fileName)) {
- console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, {
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File type not allowed',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: 0,
- }
- });
-
- return NextResponse.json(
- { error: "File type not allowed" },
- { status: 403 }
- );
- }
-
- // 안전한 파일 경로 구성
- const allowedDirs = ["public", "uploads", "storage"];
- let actualPath: string | null = null;
- let baseDir: string | null = null;
-
- // 각 허용된 디렉터리에서 파일 찾기
- for (const dir of allowedDirs) {
- baseDir = resolve(process.cwd(), dir);
- const testPath = resolve(baseDir, normalizedPath);
-
- // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단
- if (!testPath.startsWith(baseDir)) {
- continue;
- }
-
- try {
- await access(testPath, constants.R_OK);
- actualPath = testPath;
- console.log("✅ 파일 발견:", testPath);
- break;
- } catch (err) {
- // 조용히 다음 디렉터리 시도
- }
- }
-
- if (!actualPath || !baseDir) {
- console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", {
- normalizedPath,
- userId: session.user.id,
- requestedPath: path
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File not found on server',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: dbRecord.fileSize || 0,
- }
- });
-
- return NextResponse.json(
- { error: "File not found on server" },
- { status: 404 }
- );
- }
-
- // 파일 크기 확인
- const stats = await stat(actualPath);
- if (stats.size > MAX_FILE_SIZE) {
- console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, {
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File too large',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: stats.size,
- }
- });
-
- return NextResponse.json(
- { error: "File too large" },
- { status: 413 }
- );
- }
-
- // 파일 읽기
- const fileBuffer = await readFile(actualPath);
-
- // MIME 타입 결정
- const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
- let contentType = dbRecord.fileType || 'application/octet-stream';
-
- // 확장자에 따른 MIME 타입 매핑 (fallback)
- if (!contentType || contentType === 'application/octet-stream') {
- const mimeTypes: Record<string, string> = {
- 'pdf': 'application/pdf',
- 'doc': 'application/msword',
- 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'xls': 'application/vnd.ms-excel',
- 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'ppt': 'application/vnd.ms-powerpoint',
- 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain; charset=utf-8',
- 'csv': 'text/csv; charset=utf-8',
- 'png': 'image/png',
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'gif': 'image/gif',
- 'bmp': 'image/bmp',
- 'svg': 'image/svg+xml',
- 'dwg': 'application/acad',
- 'dxf': 'application/dxf',
- 'zip': 'application/zip',
- 'rar': 'application/x-rar-compressed',
- '7z': 'application/x-7z-compressed',
- };
-
- contentType = mimeTypes[fileExtension] || 'application/octet-stream';
- }
-
- // 안전한 파일명 생성
- const safeFileName = sanitizeFileName(fileName);
-
- // 보안 헤더와 다운로드용 헤더 설정
- const headers = new Headers();
- headers.set('Content-Type', contentType);
- headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`);
- headers.set('Content-Length', fileBuffer.length.toString());
-
- // 보안 헤더
- headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
- headers.set('Pragma', 'no-cache');
- headers.set('Expires', '0');
- headers.set('X-Content-Type-Options', 'nosniff');
- headers.set('X-Frame-Options', 'DENY');
- headers.set('X-XSS-Protection', '1; mode=block');
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
-
- // 성공 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: true,
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName: safeFileName,
- filePath: path,
- fileSize: fileBuffer.length,
- }
- });
-
- console.log("✅ 파일 다운로드 성공:", {
- fileName: safeFileName,
- contentType,
- size: fileBuffer.length,
- actualPath,
- userId: session.user.id,
- ip: requestInfo.ip,
- downloadDurationMs: Date.now() - startTime
- });
-
- return new NextResponse(fileBuffer, {
- status: 200,
- headers,
- });
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
-
- console.error('❌ RFQ 첨부파일 다운로드 오류:', {
- error: errorMessage,
- userId: (await getServerSession(authOptions))?.user?.id,
- ip: requestInfo.ip,
- path: request.nextUrl.searchParams.get("path"),
- downloadDurationMs: Date.now() - startTime
- });
-
- // 에러 로그 기록
- if (fileRecord?.id) {
- try {
- await createFileDownloadLog({
- fileId: fileRecord.id,
- success: false,
- errorMessage,
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName: fileRecord.fileName || 'unknown',
- filePath: request.nextUrl.searchParams.get("path") || '',
- fileSize: fileRecord.fileSize || 0,
- }
- });
- } catch (logError) {
- console.error('로그 기록 실패:', logError);
- }
- }
-
- // Zod 검증 에러 처리
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- {
- error: 'Invalid request parameters',
- details: error.errors.map(e => e.message).join(', ')
- },
- { status: 400 }
- );
- }
-
- // 에러 정보 최소화 (정보 노출 방지)
- return NextResponse.json(
- {
- error: 'Internal server error',
- details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
- },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts
deleted file mode 100644
index 93eb62db..00000000
--- a/app/api/tbe-download/route.ts
+++ /dev/null
@@ -1,417 +0,0 @@
-// app/api/tbe-download/route.ts
-import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants, stat } from 'fs/promises';
-import { join, normalize, resolve } from 'path';
-import db from '@/db/db';
-import { rfqAttachments, vendorResponseAttachments } from '@/db/schema/rfq';
-import { eq } from 'drizzle-orm';
-import { getServerSession } from 'next-auth';
-import { authOptions } from '@/app/api/auth/[...nextauth]/route';
-import { createFileDownloadLog } from '@/lib/file-download-log/service';
-import rateLimit from '@/lib/rate-limit';
-import { z } from 'zod';
-import { getRequestInfo } from '@/lib/network/get-client-ip';
-
-// 허용된 파일 확장자
-const ALLOWED_EXTENSIONS = new Set([
- 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
- 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg',
- 'dwg', 'dxf', 'zip', 'rar', '7z'
-]);
-
-// 최대 파일 크기 (50MB)
-const MAX_FILE_SIZE = 50 * 1024 * 1024;
-
-// 다운로드 요청 검증 스키마
-const downloadRequestSchema = z.object({
- path: z.string().min(1, 'File path is required'),
-});
-
-// 파일 정보 타입
-interface FileRecord {
- id: number;
- fileName: string;
- filePath: string;
- fileSize?: number;
- fileType?: string;
-}
-
-
-// 강화된 파일 경로 검증 함수
-function validateFilePath(filePath: string): boolean {
- // null, undefined, 빈 문자열 체크
- if (!filePath || typeof filePath !== 'string') {
- return false;
- }
-
- // 위험한 패턴 체크
- const dangerousPatterns = [
- /\.\./, // 상위 디렉토리 접근
- /\/\//, // 이중 슬래시
- /[<>:"'|?*]/, // 특수문자
- /[\x00-\x1f]/, // 제어문자
- /\\+/ // 백슬래시
- ];
-
- if (dangerousPatterns.some(pattern => pattern.test(filePath))) {
- return false;
- }
-
- // 시스템 파일 접근 방지
- const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home'];
- for (const dangerousPath of dangerousPaths) {
- if (filePath.toLowerCase().startsWith(dangerousPath)) {
- return false;
- }
- }
-
- return true;
-}
-
-// 파일 확장자 검증
-function validateFileExtension(fileName: string): boolean {
- const extension = fileName.split('.').pop()?.toLowerCase() || '';
- return ALLOWED_EXTENSIONS.has(extension);
-}
-
-// 안전한 파일명 생성
-function sanitizeFileName(fileName: string): string {
- return fileName
- .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거
- .replace(/\s+/g, '_') // 공백을 언더스코어로
- .substring(0, 255); // 파일명 길이 제한
-}
-
-export async function GET(request: NextRequest) {
- const startTime = Date.now();
- const requestInfo = getRequestInfo(request);
- let fileRecord: FileRecord | null = null;
-
- try {
- // Rate limiting 체크
- const limiterResult = await rateLimit(request);
- if (!limiterResult.success) {
- console.warn('🚨 Rate limit 초과:', {
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent
- });
-
- return NextResponse.json(
- { error: "Too many requests" },
- { status: 429 }
- );
- }
-
- // 세션 확인
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- console.warn('🚨 인증되지 않은 다운로드 시도:', {
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent,
- path: request.nextUrl.searchParams.get("path")
- });
-
- return NextResponse.json(
- { error: "Unauthorized" },
- { status: 401 }
- );
- }
-
- // 파라미터 검증
- const searchParams = {
- path: request.nextUrl.searchParams.get("path"),
- };
-
- const validatedParams = downloadRequestSchema.parse(searchParams);
- const { path } = validatedParams;
-
- // 파일 경로 보안 검증
- if (!validateFilePath(path)) {
- console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, {
- userId: session.user.id,
- ip: requestInfo.ip,
- userAgent: requestInfo.userAgent
- });
-
- return NextResponse.json(
- { error: "Invalid file path" },
- { status: 400 }
- );
- }
-
- // 경로 정규화
- const normalizedPath = normalize(path.replace(/^\/+/, ""));
-
- // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색)
- const [dbRecord] = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- fileType: vendorResponseAttachments.fileType,
- })
- .from(vendorResponseAttachments)
- .where(eq(vendorResponseAttachments.filePath, normalizedPath));
-
- // DB에서 파일 정보를 찾지 못한 경우
- if (!dbRecord) {
- console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", {
- path,
- normalizedPath,
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- return NextResponse.json(
- { error: "File not found in database" },
- { status: 404 }
- );
- }
-
- fileRecord = dbRecord;
-
- // 파일명 설정
- const fileName = dbRecord.fileName;
-
- // 파일 확장자 검증
- if (!validateFileExtension(fileName)) {
- console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, {
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File type not allowed',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: 0,
- }
- });
-
- return NextResponse.json(
- { error: "File type not allowed" },
- { status: 403 }
- );
- }
-
- // 안전한 파일 경로 구성
- const allowedDirs = ["public", "uploads", "storage"];
- let actualPath: string | null = null;
- let baseDir: string | null = null;
-
- // 각 허용된 디렉터리에서 파일 찾기
- for (const dir of allowedDirs) {
- baseDir = resolve(process.cwd(), dir);
- const testPath = resolve(baseDir, normalizedPath);
-
- // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단
- if (!testPath.startsWith(baseDir)) {
- continue;
- }
-
- try {
- await access(testPath, constants.R_OK);
- actualPath = testPath;
- console.log("✅ 파일 발견:", testPath);
- break;
- } catch (err) {
- console.log("❌ 경로에 파일 없음:", testPath);
- }
- }
-
- if (!actualPath || !baseDir) {
- console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", {
- normalizedPath,
- userId: session.user.id,
- requestedPath: path,
- triedDirs: allowedDirs
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File not found on server',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: dbRecord.fileSize || 0,
- }
- });
-
- return NextResponse.json(
- {
- error: "File not found on server",
- details: {
- path: path,
- fileName: fileName,
- }
- },
- { status: 404 }
- );
- }
-
- // 파일 크기 확인
- const stats = await stat(actualPath);
- if (stats.size > MAX_FILE_SIZE) {
- console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, {
- userId: session.user.id,
- ip: requestInfo.ip
- });
-
- // 실패 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: false,
- errorMessage: 'File too large',
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName,
- filePath: path,
- fileSize: stats.size,
- }
- });
-
- return NextResponse.json(
- { error: "File too large" },
- { status: 413 }
- );
- }
-
- // 파일 읽기
- const fileBuffer = await readFile(actualPath);
-
- // MIME 타입 결정
- const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
- let contentType = dbRecord.fileType || 'application/octet-stream';
-
- // 확장자에 따른 MIME 타입 매핑 (fallback)
- if (!contentType || contentType === 'application/octet-stream') {
- const mimeTypes: Record<string, string> = {
- 'pdf': 'application/pdf',
- 'doc': 'application/msword',
- 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'xls': 'application/vnd.ms-excel',
- 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'ppt': 'application/vnd.ms-powerpoint',
- 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain; charset=utf-8',
- 'csv': 'text/csv; charset=utf-8',
- 'png': 'image/png',
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'gif': 'image/gif',
- 'bmp': 'image/bmp',
- 'svg': 'image/svg+xml',
- 'dwg': 'application/acad',
- 'dxf': 'application/dxf',
- 'zip': 'application/zip',
- 'rar': 'application/x-rar-compressed',
- '7z': 'application/x-7z-compressed',
- };
-
- contentType = mimeTypes[fileExtension] || 'application/octet-stream';
- }
-
- // 안전한 파일명 생성
- const safeFileName = sanitizeFileName(fileName);
-
- // 보안 헤더와 다운로드용 헤더 설정
- const headers = new Headers();
- headers.set('Content-Type', contentType);
- headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`);
- headers.set('Content-Length', fileBuffer.length.toString());
-
- // 보안 헤더
- headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
- headers.set('Pragma', 'no-cache');
- headers.set('Expires', '0');
- headers.set('X-Content-Type-Options', 'nosniff');
- headers.set('X-Frame-Options', 'DENY');
- headers.set('X-XSS-Protection', '1; mode=block');
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
-
- // 성공 로그 기록
- await createFileDownloadLog({
- fileId: dbRecord.id,
- success: true,
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName: safeFileName,
- filePath: path,
- fileSize: fileBuffer.length,
- }
- });
-
- console.log("✅ TBE 파일 다운로드 성공:", {
- fileName: safeFileName,
- contentType,
- size: fileBuffer.length,
- actualPath,
- userId: session.user.id,
- ip: requestInfo.ip,
- downloadDurationMs: Date.now() - startTime
- });
-
- return new NextResponse(fileBuffer, {
- status: 200,
- headers,
- });
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
-
- console.error('❌ TBE 파일 다운로드 오류:', {
- error: errorMessage,
- userId: (await getServerSession(authOptions))?.user?.id,
- ip: requestInfo.ip,
- path: request.nextUrl.searchParams.get("path"),
- downloadDurationMs: Date.now() - startTime
- });
-
- // 에러 로그 기록
- if (fileRecord?.id) {
- try {
- await createFileDownloadLog({
- fileId: fileRecord.id,
- success: false,
- errorMessage,
- requestId: requestInfo.requestId,
- fileInfo: {
- fileName: fileRecord.fileName || 'unknown',
- filePath: request.nextUrl.searchParams.get("path") || '',
- fileSize: fileRecord.fileSize || 0,
- }
- });
- } catch (logError) {
- console.error('로그 기록 실패:', logError);
- }
- }
-
- // Zod 검증 에러 처리
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- {
- error: 'Invalid request parameters',
- details: error.errors.map(e => e.message).join(', ')
- },
- { status: 400 }
- );
- }
-
- // 에러 정보 최소화 (정보 노출 방지)
- return NextResponse.json(
- {
- error: 'Internal server error',
- details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
- },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/app/api/vendor-responses/update-comment/route.ts b/app/api/vendor-responses/update-comment/route.ts
deleted file mode 100644
index f1e4c487..00000000
--- a/app/api/vendor-responses/update-comment/route.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-// app/api/vendor-responses/update-comment/route.ts
-import { NextRequest, NextResponse } from "next/server";
-import db from "@/db/db";
-import { vendorAttachmentResponses } from "@/db/schema";
-
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { eq } from "drizzle-orm";
-
-export async function POST(request: NextRequest) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json(
- { message: "인증이 필요합니다." },
- { status: 401 }
- );
- }
-
- const body = await request.json();
- const { responseId, responseComment, vendorComment } = body;
-
- if (!responseId) {
- return NextResponse.json(
- { message: "응답 ID가 필요합니다." },
- { status: 400 }
- );
- }
-
- // 코멘트만 업데이트
- const [updatedResponse] = await db
- .update(vendorAttachmentResponses)
- .set({
- responseComment,
- vendorComment,
- updatedAt: new Date(),
- updatedBy:Number(session?.user.id)
- })
- .where(eq(vendorAttachmentResponses.id, parseInt(responseId)))
- .returning();
-
- if (!updatedResponse) {
- return NextResponse.json(
- { message: "응답을 찾을 수 없습니다." },
- { status: 404 }
- );
- }
-
- return NextResponse.json({
- message: "코멘트가 성공적으로 업데이트되었습니다.",
- response: updatedResponse,
- });
-
- } catch (error) {
- console.error("Comment update error:", error);
- return NextResponse.json(
- { message: "코멘트 업데이트 중 오류가 발생했습니다." },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/app/api/vendor-responses/update/route.ts b/app/api/vendor-responses/update/route.ts
deleted file mode 100644
index cf7e551c..00000000
--- a/app/api/vendor-responses/update/route.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-// app/api/vendor-responses/update/route.ts
-import { NextRequest, NextResponse } from "next/server";
-import db from "@/db/db";
-import { vendorAttachmentResponses } from "@/db/schema";
-import { eq } from "drizzle-orm";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-// 리비전 번호를 증가시키는 헬퍼 함수
-function getNextRevision(currentRevision?: string): string {
- if (!currentRevision) {
- return "Rev.0"; // 첫 번째 응답
- }
-
- // "Rev.1" -> 1, "Rev.2" -> 2 형태로 숫자 추출
- const match = currentRevision.match(/Rev\.(\d+)/);
- if (match) {
- const currentNumber = parseInt(match[1]);
- return `Rev.${currentNumber + 1}`;
- }
-
- // 형식이 다르면 기본값 반환
- return "Rev.0";
-}
-
-export async function POST(request: NextRequest) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json(
- { message: "인증이 필요합니다." },
- { status: 401 }
- );
- }
-
- const body = await request.json();
- const {
- responseId,
- responseStatus,
- responseComment,
- vendorComment,
- respondedAt,
- } = body;
-
- if (!responseId) {
- return NextResponse.json(
- { message: "응답 ID가 필요합니다." },
- { status: 400 }
- );
- }
-
- // 1. 기존 응답 정보 조회 (현재 respondedRevision 확인)
- const existingResponse = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(eq(vendorAttachmentResponses.id, parseInt(responseId)))
- .limit(1);
-
- if (!existingResponse || existingResponse.length === 0) {
- return NextResponse.json(
- { message: "응답을 찾을 수 없습니다." },
- { status: 404 }
- );
- }
-
- const currentResponse = existingResponse[0];
-
- // 2. 벤더 응답 리비전 결정
- let nextRespondedRevision: string;
-
-
- if (responseStatus === "RESPONDED") {
-
- // 첫 응답이거나 수정 요청 후 재응답인 경우 리비전 증가
- nextRespondedRevision = getNextRevision(currentResponse.respondedRevision);
-
- } else {
- // WAIVED 등 다른 상태는 기존 리비전 유지
- nextRespondedRevision = currentResponse.respondedRevision || "";
- }
-
- // 3. vendor response 업데이트
- const [updatedResponse] = await db
- .update(vendorAttachmentResponses)
- .set({
- responseStatus,
- respondedRevision: nextRespondedRevision,
- responseComment,
- vendorComment,
- respondedAt: respondedAt ? new Date(respondedAt) : null,
- updatedAt: new Date(),
- updatedBy:Number(session?.user.id)
- })
- .where(eq(vendorAttachmentResponses.id, parseInt(responseId)))
- .returning();
-
- if (!updatedResponse) {
- return NextResponse.json(
- { message: "응답 업데이트에 실패했습니다." },
- { status: 500 }
- );
- }
-
- return NextResponse.json({
- message: "응답이 성공적으로 업데이트되었습니다.",
- response: updatedResponse,
- newRevision: nextRespondedRevision, // 새로운 리비전 정보 반환
- });
-
- } catch (error) {
- console.error("Response update error:", error);
- return NextResponse.json(
- { message: "응답 업데이트 중 오류가 발생했습니다." },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/app/api/vendor-responses/upload/route.ts b/app/api/vendor-responses/upload/route.ts
deleted file mode 100644
index 111e4bd4..00000000
--- a/app/api/vendor-responses/upload/route.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-// app/api/vendor-response-attachments/upload/route.ts
-import { NextRequest, NextResponse } from "next/server";
-import { writeFile, mkdir } from "fs/promises";
-import { existsSync } from "fs";
-import path from "path";
-import db from "@/db/db";
-import { vendorResponseAttachmentsB } from "@/db/schema";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-export async function POST(request: NextRequest) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json(
- { message: "인증이 필요합니다." },
- { status: 401 }
- );
- }
-
- const formData = await request.formData();
- const responseId = formData.get("responseId") as string;
- const file = formData.get("file") as File;
- const description = formData.get("description") as string;
-
- if (!responseId) {
- return NextResponse.json(
- { message: "응답 ID가 필요합니다." },
- { status: 400 }
- );
- }
-
- if (!file) {
- return NextResponse.json(
- { message: "파일이 선택되지 않았습니다." },
- { status: 400 }
- );
- }
-
- // 파일 크기 검증 (10MB)
- if (file.size > 10 * 1024 * 1024) {
- return NextResponse.json(
- { message: "파일이 너무 큽니다. (최대 10MB)" },
- { status: 400 }
- );
- }
-
- // 업로드 디렉토리 생성
- const uploadDir = path.join(
- process.cwd(),
- "public",
- "uploads",
- "vendor-responses",
- responseId
- );
-
- if (!existsSync(uploadDir)) {
- await mkdir(uploadDir, { recursive: true });
- }
-
- // 고유한 파일명 생성
- const timestamp = Date.now();
- const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
- const fileName = `${timestamp}_${sanitizedName}`;
- const filePath = `/uploads/vendor-responses/${responseId}/${fileName}`;
- const fullPath = path.join(uploadDir, fileName);
-
- // 파일 저장
- const buffer = Buffer.from(await file.arrayBuffer());
- await writeFile(fullPath, buffer);
-
- // DB에 파일 정보 저장
- const [insertedFile] = await db
- .insert(vendorResponseAttachmentsB)
- .values({
- vendorResponseId: parseInt(responseId),
- fileName,
- originalFileName: file.name,
- filePath,
- fileSize: file.size,
- fileType: file.type || path.extname(file.name).slice(1),
- description: description || null,
- uploadedBy: parseInt(session.user.id),
- })
- .returning();
-
- return NextResponse.json({
- id: insertedFile.id,
- fileName,
- originalFileName: file.name,
- filePath,
- fileSize: file.size,
- fileType: file.type || path.extname(file.name).slice(1),
- message: "파일이 성공적으로 업로드되었습니다.",
- });
-
- } catch (error) {
- console.error("File upload error:", error);
- return NextResponse.json(
- { message: "파일 업로드 중 오류가 발생했습니다." },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/app/api/vendor-responses/waive/route.ts b/app/api/vendor-responses/waive/route.ts
deleted file mode 100644
index e732e8d2..00000000
--- a/app/api/vendor-responses/waive/route.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-// app/api/vendor-responses/waive/route.ts
-import { NextRequest, NextResponse } from "next/server";
-import db from "@/db/db";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { eq } from "drizzle-orm";
-import { vendorAttachmentResponses } from "@/db/schema";
-
-export async function POST(request: NextRequest) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json(
- { message: "인증이 필요합니다." },
- { status: 401 }
- );
- }
-
- const body = await request.json();
- const { responseId, responseComment, vendorComment } = body;
-
- if (!responseId) {
- return NextResponse.json(
- { message: "응답 ID가 필요합니다." },
- { status: 400 }
- );
- }
-
- if (!responseComment) {
- return NextResponse.json(
- { message: "포기 사유를 입력해주세요." },
- { status: 400 }
- );
- }
-
- // vendor response를 WAIVED 상태로 업데이트
- const [updatedResponse] = await db
- .update(vendorAttachmentResponses)
- .set({
- responseStatus: "WAIVED",
- responseComment,
- vendorComment,
- respondedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(vendorAttachmentResponses.id, parseInt(responseId)))
- .returning();
-
- if (!updatedResponse) {
- return NextResponse.json(
- { message: "응답을 찾을 수 없습니다." },
- { status: 404 }
- );
- }
-
- return NextResponse.json({
- message: "응답이 성공적으로 포기 처리되었습니다.",
- response: updatedResponse,
- });
-
- } catch (error) {
- console.error("Waive response error:", error);
- return NextResponse.json(
- { message: "응답 포기 처리 중 오류가 발생했습니다." },
- { status: 500 }
- );
- }
-} \ No newline at end of file
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx
index 58fa2c23..45963d88 100644
--- a/components/ProjectSelector.tsx
+++ b/components/ProjectSelector.tsx
@@ -6,7 +6,14 @@ 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 { getProjects, type Project } from "@/lib/rfqs/service"
+import { getProjects } from "@/lib/projects/service"
+
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ type: string;
+}
interface ProjectSelectorProps {
selectedProjectId?: number | null;
diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx
index a87c8dce..8a4b85af 100644
--- a/components/bidding/ProjectSelectorBid.tsx
+++ b/components/bidding/ProjectSelectorBid.tsx
@@ -6,7 +6,7 @@ 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 { getProjects, type Project } from "@/lib/rfqs/service"
+import { getProjects } from "@/lib/projects/service"
interface ProjectSelectorProps {
selectedProjectId?: number | null;
@@ -16,6 +16,13 @@ interface ProjectSelectorProps {
disabled?: boolean;
}
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ type: string;
+}
+
export function ProjectSelector({
selectedProjectId,
onProjectSelect,
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx
index 0c83e858..2752948a 100644
--- a/components/layout/Header.tsx
+++ b/components/layout/Header.tsx
@@ -123,7 +123,60 @@ export function Header() {
}, [pathname]);
// 도메인별 메뉴 및 브랜딩 정보 가져오기
- const getDomainConfig = (pathname: string | null) => {
+ // session.user.domain이 있으면 그것을 우선적으로 따르고, 없으면 pathname을 따릅니다.
+ const userDomain = (session?.user as { domain?: string } | undefined)?.domain;
+
+ const getDomainConfig = (pathname: string | null, domain?: string) => {
+ // 1. 세션 도메인 기반 설정
+ if (domain) {
+ if (domain === "partners") {
+ return {
+ main: mainNavVendor,
+ additional: additionalNavVendor,
+ logoHref: `/${lng}/partners`,
+ brandNameKey: domainBrandingKeys.partners,
+ basePath: `/${lng}/partners`
+ };
+ }
+ if (domain === "procurement") {
+ return {
+ main: procurementNav,
+ additional: additional2Nav,
+ logoHref: `/${lng}/procurement`,
+ brandNameKey: domainBrandingKeys.procurement,
+ basePath: `/${lng}/procurement`
+ };
+ }
+ if (domain === "sales") {
+ return {
+ main: salesNav,
+ additional: additional2Nav,
+ logoHref: `/${lng}/sales`,
+ brandNameKey: domainBrandingKeys.sales,
+ basePath: `/${lng}/sales`
+ };
+ }
+ if (domain === "engineering") {
+ return {
+ main: engineeringNav,
+ additional: additional2Nav,
+ logoHref: `/${lng}/engineering`,
+ brandNameKey: domainBrandingKeys.engineering,
+ basePath: `/${lng}/engineering`
+ };
+ }
+ if (domain === "evcp") {
+ return {
+ main: mainNav,
+ additional: additionalNav,
+ logoHref: `/${lng}/evcp`,
+ brandNameKey: domainBrandingKeys.evcp,
+ basePath: `/${lng}/evcp`
+ };
+ }
+ }
+
+ // 2. 경로 기반 설정 (Fallback)
if (pathname?.includes("/partners")) {
return {
main: mainNavVendor,
@@ -174,7 +227,7 @@ export function Header() {
};
};
- const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname);
+ const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname, userDomain);
// partners 도메인 여부 확인
const isPartners = pathname?.includes("/partners") ?? false;
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 860c2a88..76f1302e 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -221,24 +221,6 @@ export const mainNav: MenuSection[] = [
href: '/evcp/avl',
descriptionKey: 'menu.vendor_management.avl_management_desc',
},
- // 기존 project avl
- // {
- // titleKey: "menu.vendor_management.project_avl",
- // href: "/evcp/project-vendors",
- // descriptionKey: "menu.vendor_management.project_avl_desc",
- // },
- {
- titleKey: 'menu.vendor_management.legalReview',
- href: '/evcp/legal-review',
- // descriptionKey: "menu.vendor_management.legalReview_desc",
- groupKey: 'groups.legal',
- },
- {
- titleKey: 'menu.vendor_management.legalResponse',
- href: '/evcp/legal-response',
- // descriptionKey: "menu.vendor_management.legalResponse_desc",
- groupKey: 'groups.legal',
- },
{
titleKey: 'menu.vendor_management.risk_by_agency',
href: '/evcp/risk-management',
diff --git a/lib/b-rfq/attachment/add-attachment-dialog.tsx b/lib/b-rfq/attachment/add-attachment-dialog.tsx
deleted file mode 100644
index 665e0f88..00000000
--- a/lib/b-rfq/attachment/add-attachment-dialog.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Plus ,X} from "lucide-react"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { addRfqAttachmentRecord } from "../service"
-
-// 첨부파일 추가 폼 스키마 (단일 파일)
-const addAttachmentSchema = z.object({
- attachmentType: z.enum(["구매", "설계"], {
- required_error: "문서 타입을 선택해주세요.",
- }),
- description: z.string().optional(),
- file: z.instanceof(File, {
- message: "파일을 선택해주세요.",
- }),
-})
-
-type AddAttachmentFormData = z.infer<typeof addAttachmentSchema>
-
-interface AddAttachmentDialogProps {
- rfqId: number
-}
-
-export function AddAttachmentDialog({ rfqId }: AddAttachmentDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [uploadProgress, setUploadProgress] = React.useState<number>(0)
-
- const form = useForm<AddAttachmentFormData>({
- resolver: zodResolver(addAttachmentSchema),
- defaultValues: {
- attachmentType: undefined,
- description: "",
- file: undefined,
- },
- })
-
- const selectedFile = form.watch("file")
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset()
- }
- setOpen(newOpen)
- }
-
- // 파일 선택 처리
- const handleFileChange = (files: File[]) => {
- if (files.length === 0) return
-
- const file = files[0] // 첫 번째 파일만 사용
-
- // 파일 크기 검증
- const maxFileSize = 10 * 1024 * 1024 // 10MB
- if (file.size > maxFileSize) {
- toast.error(`파일이 너무 큽니다. (최대 10MB)`)
- return
- }
-
- form.setValue("file", file)
- form.clearErrors("file")
- }
-
- // 파일 제거
- const removeFile = () => {
- form.resetField("file")
- }
-
- // 파일 업로드 API 호출
- const uploadFile = async (file: File): Promise<{
- fileName: string
- originalFileName: string
- filePath: string
- fileSize: number
- fileType: string
- }> => {
- const formData = new FormData()
- formData.append("rfqId", rfqId.toString())
- formData.append("file", file)
-
- const response = await fetch("/api/upload/rfq-attachment", {
- method: "POST",
- body: formData,
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.message || "파일 업로드 실패")
- }
-
- return response.json()
- }
-
- // 폼 제출
- const onSubmit = async (data: AddAttachmentFormData) => {
- setIsSubmitting(true)
- setUploadProgress(0)
-
- try {
- // 1단계: 파일 업로드
- setUploadProgress(30)
- const uploadedFile = await uploadFile(data.file)
-
- // 2단계: DB 레코드 생성 (시리얼 번호 자동 생성)
- setUploadProgress(70)
- const attachmentRecord = {
- rfqId,
- attachmentType: data.attachmentType,
- description: data.description,
- fileName: uploadedFile.fileName,
- originalFileName: uploadedFile.originalFileName,
- filePath: uploadedFile.filePath,
- fileSize: uploadedFile.fileSize,
- fileType: uploadedFile.fileType,
- }
-
- const result = await addRfqAttachmentRecord(attachmentRecord)
-
- setUploadProgress(100)
-
- if (result.success) {
- toast.success(result.message)
- form.reset()
- handleOpenChange(false)
- } else {
- toast.error(result.message)
- }
-
- } catch (error) {
- console.error("Upload error:", error)
- toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- setUploadProgress(0)
- }
- }
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">새 첨부</span>
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>새 첨부파일 추가</DialogTitle>
- <DialogDescription>
- RFQ에 첨부할 문서를 업로드합니다. 시리얼 번호는 자동으로 부여됩니다.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 문서 타입 선택 */}
- <FormField
- control={form.control}
- name="attachmentType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문서 타입</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="문서 타입을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="구매">구매</SelectItem>
- <SelectItem value="설계">설계</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="첨부파일에 대한 설명을 입력하세요"
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 선택 - Dropzone (단일 파일) */}
- <FormField
- control={form.control}
- name="file"
- render={({ field }) => (
- <FormItem>
- <FormLabel>파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-3">
- <Dropzone
- onDrop={(acceptedFiles) => {
- handleFileChange(acceptedFiles)
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar']
- }}
- maxSize={10 * 1024 * 1024} // 10MB
- multiple={false} // 단일 파일만
- disabled={isSubmitting}
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle>
- <DropzoneDescription>
- PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개)
- </DropzoneDescription>
- <DropzoneInput />
- </DropzoneZone>
- </Dropzone>
-
- {/* 선택된 파일 표시 */}
- {selectedFile && (
- <div className="space-y-2">
- <FileListHeader>
- 선택된 파일
- </FileListHeader>
- <FileList>
- <FileListItem className="flex items-center justify-between gap-3">
- <FileListIcon />
- <FileListInfo>
- <FileListName>{selectedFile.name}</FileListName>
- <FileListDescription>
- <FileListSize>{selectedFile.size}</FileListSize>
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={removeFile}
- disabled={isSubmitting}
- >
- <X className="h-4 w-4" />
- </FileListAction>
- </FileListItem>
- </FileList>
- </div>
- )}
-
- {/* 업로드 진행률 */}
- {isSubmitting && uploadProgress > 0 && (
- <div className="space-y-2">
- <div className="flex justify-between text-sm">
- <span>업로드 진행률</span>
- <span>{uploadProgress}%</span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-blue-600 h-2 rounded-full transition-all duration-300"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting || !selectedFile}>
- {isSubmitting ? "업로드 중..." : "업로드"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/add-revision-dialog.tsx b/lib/b-rfq/attachment/add-revision-dialog.tsx
deleted file mode 100644
index 1abefb02..00000000
--- a/lib/b-rfq/attachment/add-revision-dialog.tsx
+++ /dev/null
@@ -1,336 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { addRevisionToAttachment } from "../service"
-
-// 리비전 추가 폼 스키마
-const addRevisionSchema = z.object({
- revisionComment: z.string().optional(),
- file: z.instanceof(File, {
- message: "파일을 선택해주세요.",
- }),
-})
-
-type AddRevisionFormData = z.infer<typeof addRevisionSchema>
-
-interface AddRevisionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- attachmentId: number
- currentRevision: string
- originalFileName: string
- onSuccess?: () => void
-}
-
-export function AddRevisionDialog({
- open,
- onOpenChange,
- attachmentId,
- currentRevision,
- originalFileName,
- onSuccess
-}: AddRevisionDialogProps) {
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [uploadProgress, setUploadProgress] = React.useState<number>(0)
-
- const form = useForm<AddRevisionFormData>({
- resolver: zodResolver(addRevisionSchema),
- defaultValues: {
- revisionComment: "",
- file: undefined,
- },
- })
-
- const selectedFile = form.watch("file")
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset()
- }
- onOpenChange(newOpen)
- }
-
- // 파일 선택 처리
- const handleFileChange = (files: File[]) => {
- if (files.length === 0) return
-
- const file = files[0]
-
- // 파일 크기 검증
- const maxFileSize = 10 * 1024 * 1024 // 10MB
- if (file.size > maxFileSize) {
- toast.error(`파일이 너무 큽니다. (최대 10MB)`)
- return
- }
-
- form.setValue("file", file)
- form.clearErrors("file")
- }
-
- // 파일 제거
- const removeFile = () => {
- form.resetField("file")
- }
-
- // 파일 업로드 API 호출
- const uploadFile = async (file: File): Promise<{
- fileName: string
- originalFileName: string
- filePath: string
- fileSize: number
- fileType: string
- }> => {
- const formData = new FormData()
- formData.append("attachmentId", attachmentId.toString())
- formData.append("file", file)
- formData.append("isRevision", "true")
-
- const response = await fetch("/api/upload/rfq-attachment-revision", {
- method: "POST",
- body: formData,
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.message || "파일 업로드 실패")
- }
-
- return response.json()
- }
-
- // 폼 제출
- const onSubmit = async (data: AddRevisionFormData) => {
- setIsSubmitting(true)
- setUploadProgress(0)
-
- try {
- // 1단계: 파일 업로드
- setUploadProgress(30)
- const uploadedFile = await uploadFile(data.file)
-
- // 2단계: DB 리비전 레코드 생성
- setUploadProgress(70)
- const result = await addRevisionToAttachment(attachmentId, {
- fileName: uploadedFile.fileName,
- originalFileName: uploadedFile.originalFileName,
- filePath: uploadedFile.filePath,
- fileSize: uploadedFile.fileSize,
- fileType: uploadedFile.fileType,
- revisionComment: data.revisionComment,
- })
-
- setUploadProgress(100)
-
- if (result.success) {
- toast.success(result.message)
- form.reset()
- handleOpenChange(false)
- onSuccess?.()
- } else {
- toast.error(result.message)
- }
-
- } catch (error) {
- console.error("Upload error:", error)
- toast.error(error instanceof Error ? error.message : "리비전 추가 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- setUploadProgress(0)
- }
- }
-
- // 다음 리비전 번호 계산
- const getNextRevision = (current: string) => {
- const match = current.match(/Rev\.(\d+)/)
- if (match) {
- const num = parseInt(match[1]) + 1
- return `Rev.${num}`
- }
- return "Rev.1"
- }
-
- const nextRevision = getNextRevision(currentRevision)
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5" />
- 새 리비전 추가
- </DialogTitle>
- <DialogDescription>
- "{originalFileName}"의 새 버전을 업로드합니다.
- 현재 {currentRevision} → {nextRevision}
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 리비전 코멘트 */}
- <FormField
- control={form.control}
- name="revisionComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전 코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder={`${nextRevision} 업데이트 내용을 입력하세요`}
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 선택 - Dropzone (단일 파일) */}
- <FormField
- control={form.control}
- name="file"
- render={({ field }) => (
- <FormItem>
- <FormLabel>새 파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-3">
- <Dropzone
- onDrop={(acceptedFiles) => {
- handleFileChange(acceptedFiles)
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar']
- }}
- maxSize={10 * 1024 * 1024} // 10MB
- multiple={false}
- disabled={isSubmitting}
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle>
- <DropzoneDescription>
- PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개)
- </DropzoneDescription>
- <DropzoneInput />
- </DropzoneZone>
- </Dropzone>
-
- {/* 선택된 파일 표시 */}
- {selectedFile && (
- <div className="space-y-2">
- <FileListHeader>
- 선택된 파일 ({nextRevision})
- </FileListHeader>
- <FileList>
- <FileListItem>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{selectedFile.name}</FileListName>
- <FileListDescription>
- <FileListSize>{selectedFile.size}</FileListSize>
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={removeFile}
- disabled={isSubmitting}
- />
- </FileListItem>
- </FileList>
- </div>
- )}
-
- {/* 업로드 진행률 */}
- {isSubmitting && uploadProgress > 0 && (
- <div className="space-y-2">
- <div className="flex justify-between text-sm">
- <span>업로드 진행률</span>
- <span>{uploadProgress}%</span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-blue-600 h-2 rounded-full transition-all duration-300"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting || !selectedFile}>
- {isSubmitting ? "업로드 중..." : `${nextRevision} 추가`}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/attachment-columns.tsx b/lib/b-rfq/attachment/attachment-columns.tsx
deleted file mode 100644
index b726ebc8..00000000
--- a/lib/b-rfq/attachment/attachment-columns.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import {
- Ellipsis, FileText, Download, Eye,
- MessageSquare, Upload
-} from "lucide-react"
-
-import { formatDate, formatBytes } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu, DropdownMenuContent, DropdownMenuItem,
- DropdownMenuSeparator, DropdownMenuTrigger
-} from "@/components/ui/dropdown-menu"
-import { Progress } from "@/components/ui/progress"
-import { RevisionDialog } from "./revision-dialog"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { AddRevisionDialog } from "./add-revision-dialog"
-
-interface GetAttachmentColumnsProps {
- onSelectAttachment: (attachment: any) => void
-}
-
-export function getAttachmentColumns({
- onSelectAttachment
-}: GetAttachmentColumnsProps): ColumnDef<any>[] {
-
- return [
- /** ───────────── 체크박스 ───────────── */
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- /** ───────────── 문서 정보 ───────────── */
- {
- accessorKey: "serialNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="시리얼 번호" />
- ),
- cell: ({ row }) => (
- <Button
- variant="link"
- className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800"
- onClick={() => onSelectAttachment(row.original)}
- >
- {row.getValue("serialNo") as string}
- </Button>
- ),
- size: 100,
- },
- {
- accessorKey: "attachmentType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="문서 타입" />
- ),
- cell: ({ row }) => {
- const type = row.getValue("attachmentType") as string
- return (
- <Badge variant={type === "구매" ? "default" : "secondary"}>
- {type}
- </Badge>
- )
- },
- size:100
- },
- {
- accessorKey: "originalFileName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="파일명" />
- ),
- cell: ({ row }) => {
- const fileName = row.getValue("originalFileName") as string
- return (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div className="min-w-0 flex-1">
- <div className="truncate font-medium" title={fileName}>
- {fileName}
- </div>
- </div>
- </div>
- )
- },
- size:250
- },
- {
- id: "currentRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="리비전" />
- ),
- cell: ({ row }) => (
- <RevisionDialog
- attachmentId={row.original.id}
- currentRevision={row.original.currentRevision}
- originalFileName={row.original.originalFileName}
- />
- ),
- size: 100,
- },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="설명" />
- ),
- cell: ({ row }) => {
- const description = row.getValue("description") as string
- return description
- ? <div className="max-w-[200px] truncate" title={description}>{description}</div>
- : <span className="text-muted-foreground">-</span>
- },
- },
-
- /** ───────────── 파일 정보 ───────────── */
- // {
- // accessorKey: "fileSize",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="파일 크기" />
- // ),
- // cell: ({ row }) => {
- // const size = row.getValue("fileSize") as number
- // return size ? formatBytes(size) : "-"
- // },
- // },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ row }) => {
- const created = row.getValue("createdAt") as Date
- const updated = row.original.updatedAt as Date
- return (
- <div>
- <div>{formatDate(created, "KR")}</div>
- <div className="text-xs text-muted-foreground">
- {row.original.createdByName}
- </div>
- {updated && new Date(updated) > new Date(created) && (
- <div className="text-xs text-blue-600">
- 수정: {formatDate(updated, "KR")}
- </div>
- )}
- </div>
- )
- },
- maxSize:150
- },
-
- /** ───────────── 벤더 응답 현황 ───────────── */
- {
- id: "vendorCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 수" />
- ),
- cell: ({ row }) => {
- const stats = row.original.responseStats
- return stats
- ? (
- <div className="text-center">
- <div className="font-medium">{stats.totalVendors}</div>
- <div className="text-xs text-muted-foreground">
- 활성: {stats.totalVendors - stats.waivedCount}
- </div>
- </div>
- )
- : <span className="text-muted-foreground">-</span>
- },
- },
- {
- id: "responseStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="응답 현황" />
- ),
- cell: ({ row }) => {
- const stats = row.original.responseStats
- return stats
- ? (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <div className="flex-1">
- <Progress value={stats.responseRate} className="h-2" />
- </div>
- <span className="text-sm font-medium">
- {stats.responseRate}%
- </span>
- </div>
- <div className="flex gap-2 text-xs">
- <span className="text-green-600">
- 응답: {stats.respondedCount}
- </span>
- <span className="text-orange-600">
- 대기: {stats.pendingCount}
- </span>
- {stats.waivedCount > 0 && (
- <span className="text-gray-500">
- 면제: {stats.waivedCount}
- </span>
- )}
- </div>
- </div>
- )
- : <span className="text-muted-foreground">-</span>
- },
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false)
-
- return (
- <>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
- <DropdownMenuItem onClick={() => onSelectAttachment(row.original)}>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 응답 보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => row.original.filePath && window.open(row.original.filePath, "_blank")}
- >
- <Download className="mr-2 h-4 w-4" />
- 다운로드
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setIsAddRevisionOpen(true)}>
- <Upload className="mr-2 h-4 w-4" />
- 새 리비전 추가
- </DropdownMenuItem>
- <DropdownMenuItem className="text-red-600">
- 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- <AddRevisionDialog
- open={isAddRevisionOpen}
- onOpenChange={setIsAddRevisionOpen}
- attachmentId={row.original.id}
- currentRevision={row.original.currentRevision}
- originalFileName={row.original.originalFileName}
- onSuccess={() => window.location.reload()}
- />
- </>
- )
- },
- size: 40,
- },
- ]
-}
diff --git a/lib/b-rfq/attachment/attachment-table.tsx b/lib/b-rfq/attachment/attachment-table.tsx
deleted file mode 100644
index 4c547000..00000000
--- a/lib/b-rfq/attachment/attachment-table.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { VendorResponsesPanel } from "./vendor-responses-panel"
-import { Separator } from "@/components/ui/separator"
-import { FileText } from "lucide-react"
-import { getRfqAttachments, getVendorResponsesForAttachment } from "../service"
-import { getAttachmentColumns } from "./attachment-columns"
-import { RfqAttachmentsTableToolbarActions } from "./attachment-toolbar-action"
-
-interface RfqAttachmentsTableProps {
- promises: Promise<Awaited<ReturnType<typeof getRfqAttachments>>>
- rfqId: number
-}
-
-export function RfqAttachmentsTable({ promises, rfqId }: RfqAttachmentsTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 첨부파일과 벤더 응답 데이터
- const [selectedAttachment, setSelectedAttachment] = React.useState<any>(null)
- const [vendorResponses, setVendorResponses] = React.useState<any[]>([])
- const [isLoadingResponses, setIsLoadingResponses] = React.useState(false)
-
- const columns = React.useMemo(
- () => getAttachmentColumns({
- onSelectAttachment: setSelectedAttachment
- }),
- []
- )
-
- // 첨부파일 선택 시 벤더 응답 데이터 로드
- React.useEffect(() => {
- if (!selectedAttachment) {
- setVendorResponses([])
- return
- }
-
- const loadVendorResponses = async () => {
- setIsLoadingResponses(true)
- try {
- const responses = await getVendorResponsesForAttachment(
- selectedAttachment.id,
- 'INITIAL' // 또는 현재 RFQ 상태에 따라 결정
- )
- setVendorResponses(responses)
- } catch (error) {
- console.error('Failed to load vendor responses:', error)
- setVendorResponses([])
- } finally {
- setIsLoadingResponses(false)
- }
- }
-
- loadVendorResponses()
- }, [selectedAttachment])
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "fileName",
- label: "파일명",
- placeholder: "파일명으로 검색...",
- },
- {
- id: "attachmentType",
- label: "문서 타입",
- options: [
- { label: "구매 문서", value: "구매", count: 0 },
- { label: "설계 문서", value: "설계", count: 0 },
- ],
- },
- {
- id: "fileType",
- label: "파일 형식",
- options: [
- { label: "PDF", value: "pdf", count: 0 },
- { label: "Excel", value: "xlsx", count: 0 },
- { label: "Word", value: "docx", count: 0 },
- { label: "기타", value: "기타", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "fileName",
- label: "파일명",
- type: "text",
- },
- {
- id: "originalFileName",
- label: "원본 파일명",
- type: "text",
- },
- {
- id: "serialNo",
- label: "시리얼 번호",
- type: "text",
- },
- {
- id: "description",
- label: "설명",
- type: "text",
- },
- {
- id: "attachmentType",
- label: "문서 타입",
- type: "multi-select",
- options: [
- { label: "구매 문서", value: "구매" },
- { label: "설계 문서", value: "설계" },
- ],
- },
- {
- id: "fileType",
- label: "파일 형식",
- type: "multi-select",
- options: [
- { label: "PDF", value: "pdf" },
- { label: "Excel", value: "xlsx" },
- { label: "Word", value: "docx" },
- { label: "기타", value: "기타" },
- ],
- },
- {
- id: "createdAt",
- label: "등록일",
- type: "date",
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.id.toString(),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <div className="space-y-6">
- {/* 메인 테이블 */}
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <RfqAttachmentsTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* 벤더 응답 현황 패널 */}
- {selectedAttachment && (
- <>
- <Separator />
- <VendorResponsesPanel
- attachment={selectedAttachment}
- responses={vendorResponses}
- isLoading={isLoadingResponses}
- onRefresh={() => {
- // 새로고침 로직
- if (selectedAttachment) {
- setSelectedAttachment({ ...selectedAttachment })
- }
- }}
- />
- </>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/attachment-toolbar-action.tsx b/lib/b-rfq/attachment/attachment-toolbar-action.tsx
deleted file mode 100644
index e078ea66..00000000
--- a/lib/b-rfq/attachment/attachment-toolbar-action.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-
-import { AddAttachmentDialog } from "./add-attachment-dialog"
-import { ConfirmDocumentsDialog } from "./confirm-documents-dialog"
-import { TbeRequestDialog } from "./tbe-request-dialog"
-import { DeleteAttachmentsDialog } from "./delete-attachment-dialog"
-
-interface RfqAttachmentsTableToolbarActionsProps {
- table: Table<any>
- rfqId: number
-}
-
-export function RfqAttachmentsTableToolbarActions({
- table,
- rfqId
-}: RfqAttachmentsTableToolbarActionsProps) {
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedAttachments = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- return (
- <div className="flex items-center gap-2">
- {/** 선택된 로우가 있으면 삭제 다이얼로그 */}
- {selectedCount > 0 && (
- <DeleteAttachmentsDialog
- attachments={selectedAttachments}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- )}
-
- {/** 새 첨부 추가 다이얼로그 */}
- <AddAttachmentDialog rfqId={rfqId} />
-
- {/** 문서 확정 다이얼로그 */}
- <ConfirmDocumentsDialog
- rfqId={rfqId}
- onSuccess={() => {
- // 성공 후 필요한 작업 (예: 페이지 새로고침)
- window.location.reload()
- }}
- />
-
- {/** TBE 요청 다이얼로그 (선택된 행이 있을 때만 활성화) */}
- <TbeRequestDialog
- rfqId={rfqId}
- attachments={selectedAttachments}
- onSuccess={() => {
- // 선택 해제 및 페이지 새로고침
- table.toggleAllRowsSelected(false)
- window.location.reload()
- }}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/confirm-documents-dialog.tsx b/lib/b-rfq/attachment/confirm-documents-dialog.tsx
deleted file mode 100644
index fccb4123..00000000
--- a/lib/b-rfq/attachment/confirm-documents-dialog.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Loader, FileCheck } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { confirmDocuments } from "../service"
-
-interface ConfirmDocumentsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function ConfirmDocumentsDialog({
- rfqId,
- showTrigger = true,
- onSuccess,
- ...props
-}: ConfirmDocumentsDialogProps) {
- const [isConfirmPending, startConfirmTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onConfirm() {
- startConfirmTransition(async () => {
- const result = await confirmDocuments(rfqId)
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <FileCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">문서 확정</span>
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>문서를 확정하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다.
- 확정 후에는 문서 수정이 제한될 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Confirm documents"
- onClick={onConfirm}
- disabled={isConfirmPending}
- >
- {isConfirmPending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 문서 확정
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <FileCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">문서 확정</span>
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>문서를 확정하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다.
- 확정 후에는 문서 수정이 제한될 수 있습니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Confirm documents"
- onClick={onConfirm}
- disabled={isConfirmPending}
- >
- {isConfirmPending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 문서 확정
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/delete-attachment-dialog.tsx b/lib/b-rfq/attachment/delete-attachment-dialog.tsx
deleted file mode 100644
index b5471520..00000000
--- a/lib/b-rfq/attachment/delete-attachment-dialog.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { deleteRfqAttachments } from "../service"
-
-
-// 첨부파일 타입 (실제 타입에 맞게 조정 필요)
-type RfqAttachment = {
- id: number
- serialNo: string
- originalFileName: string
- attachmentType: string
- currentRevision: string
-}
-
-interface DeleteAttachmentsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- attachments: Row<RfqAttachment>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteAttachmentsDialog({
- attachments,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteAttachmentsDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startDeleteTransition(async () => {
- const result = await deleteRfqAttachments({
- ids: attachments.map((attachment) => attachment.id),
- })
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- const attachmentText = attachments.length === 1 ? "첨부파일" : "첨부파일들"
- const deleteWarning = `선택된 ${attachments.length}개의 ${attachmentText}과 모든 리비전이 영구적으로 삭제됩니다.`
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({attachments.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription className="space-y-2">
- <div>이 작업은 되돌릴 수 없습니다.</div>
- <div>{deleteWarning}</div>
- {attachments.length <= 3 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">삭제될 파일:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected attachments"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({attachments.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription className="space-y-2">
- <div>이 작업은 되돌릴 수 없습니다.</div>
- <div>{deleteWarning}</div>
- {attachments.length <= 3 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">삭제될 파일:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected attachments"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/request-revision-dialog.tsx b/lib/b-rfq/attachment/request-revision-dialog.tsx
deleted file mode 100644
index 90d5b543..00000000
--- a/lib/b-rfq/attachment/request-revision-dialog.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-// components/rfq/request-revision-dialog.tsx
-"use client";
-
-import { useState, useTransition } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { AlertTriangle, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { requestRevision } from "../service";
-
-const revisionFormSchema = z.object({
- revisionReason: z
- .string()
- .min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요")
- .max(500, "수정 요청 사유는 500자를 초과할 수 없습니다"),
-});
-
-type RevisionFormData = z.infer<typeof revisionFormSchema>;
-
-interface RequestRevisionDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- vendorName?: string;
- currentRevision: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function RequestRevisionDialog({
- responseId,
- attachmentType,
- serialNo,
- vendorName,
- currentRevision,
- trigger,
- onSuccess,
-}: RequestRevisionDialogProps) {
- const [open, setOpen] = useState(false);
- const [isPending, startTransition] = useTransition();
- const { toast } = useToast();
-
- const form = useForm<RevisionFormData>({
- resolver: zodResolver(revisionFormSchema),
- defaultValues: {
- revisionReason: "",
- },
- });
-
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen);
- // 다이얼로그가 닫힐 때 form 리셋
- if (!newOpen) {
- form.reset();
- }
- };
-
- const handleCancel = () => {
- form.reset();
- setOpen(false);
- };
-
- const onSubmit = async (data: RevisionFormData) => {
- startTransition(async () => {
- try {
- const result = await requestRevision(responseId, data.revisionReason);
-
- if (!result.success) {
- throw new Error(result.message);
- }
-
- toast({
- title: "수정 요청 완료",
- description: result.message,
- });
-
- setOpen(false);
- form.reset();
- onSuccess?.();
-
- } catch (error) {
- console.error("Request revision error:", error);
- toast({
- title: "수정 요청 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- }
- });
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <AlertTriangle className="h-3 w-3 mr-1" />
- 수정요청
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertTriangle className="h-5 w-5 text-orange-600" />
- 수정 요청
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- <Badge variant="secondary">{currentRevision}</Badge>
- {vendorName && (
- <>
- <span>•</span>
- <span>{vendorName}</span>
- </>
- )}
- </div>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
- <div className="flex items-start gap-2">
- <AlertTriangle className="h-4 w-4 text-orange-600 mt-0.5 flex-shrink-0" />
- <div className="text-sm text-orange-800">
- <p className="font-medium mb-1">수정 요청 안내</p>
- <p>
- 벤더에게 현재 제출된 응답에 대한 수정을 요청합니다.
- 수정 요청 후 벤더는 새로운 파일을 다시 제출할 수 있습니다.
- </p>
- </div>
- </div>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="revisionReason"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-base font-medium">
- 수정 요청 사유 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Textarea
- placeholder="수정이 필요한 구체적인 사유를 입력해주세요...&#10;예: 제출된 도면에서 치수 정보가 누락되었습니다."
- className="resize-none"
- rows={4}
- disabled={isPending}
- {...field}
- />
- </FormControl>
- <div className="flex justify-between text-xs text-muted-foreground">
- <FormMessage />
- <span>{field.value?.length || 0}/500</span>
- </div>
- </FormItem>
- )}
- />
-
- <div className="flex justify-end gap-2 pt-2">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isPending}
- // className="bg-orange-600 hover:bg-orange-700"
- >
- {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isPending ? "요청 중..." : "수정 요청"}
- </Button>
- </div>
- </form>
- </Form>
- </div>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/revision-dialog.tsx b/lib/b-rfq/attachment/revision-dialog.tsx
deleted file mode 100644
index d26abedb..00000000
--- a/lib/b-rfq/attachment/revision-dialog.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { History, Download, Upload } from "lucide-react"
-import { formatDate, formatBytes } from "@/lib/utils"
-import { getAttachmentRevisions } from "../service"
-import { AddRevisionDialog } from "./add-revision-dialog"
-
-interface RevisionDialogProps {
- attachmentId: number
- currentRevision: string
- originalFileName: string
-}
-
-export function RevisionDialog({
- attachmentId,
- currentRevision,
- originalFileName
-}: RevisionDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [revisions, setRevisions] = React.useState<any[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
- const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false)
-
- // 리비전 목록 로드
- const loadRevisions = async () => {
- setIsLoading(true)
- try {
- const result = await getAttachmentRevisions(attachmentId)
-
- if (result.success) {
- setRevisions(result.revisions)
- } else {
- console.error("Failed to load revisions:", result.message)
- }
- } catch (error) {
- console.error("Failed to load revisions:", error)
- } finally {
- setIsLoading(false)
- }
- }
-
- React.useEffect(() => {
- if (open) {
- loadRevisions()
- }
- }, [open, attachmentId])
-
- return (
- <>
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button variant="ghost" size="sm" className="gap-2">
- <History className="h-4 w-4" />
- {currentRevision}
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[800px]" style={{maxWidth:800}}>
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <History className="h-5 w-5" />
- 리비전 히스토리: {originalFileName}
- </DialogTitle>
- <DialogDescription>
- 이 문서의 모든 버전을 확인하고 관리할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 새 리비전 추가 버튼 */}
- <div className="flex justify-end">
- <Button
- onClick={() => setIsAddRevisionOpen(true)}
- className="gap-2"
- >
- <Upload className="h-4 w-4" />
- 새 리비전 추가
- </Button>
- </div>
-
- {/* 리비전 목록 */}
- {isLoading ? (
- <div className="text-center py-8">리비전을 불러오는 중...</div>
- ) : (
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>리비전</TableHead>
- <TableHead>파일명</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>업로드 일시</TableHead>
- <TableHead>업로드자</TableHead>
- <TableHead>코멘트</TableHead>
- <TableHead>액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {revisions.map((revision) => (
- <TableRow key={revision.id}>
- <TableCell>
- <div className="flex items-center gap-2">
- <Badge
- variant={revision.isLatest ? "default" : "outline"}
- >
- {revision.revisionNo}
- </Badge>
- {revision.isLatest && (
- <Badge variant="secondary" className="text-xs">
- 최신
- </Badge>
- )}
- </div>
- </TableCell>
-
- <TableCell>
- <div>
- <div className="font-medium">{revision.originalFileName}</div>
- </div>
- </TableCell>
-
- <TableCell>
- {formatBytes(revision.fileSize)}
- </TableCell>
-
- <TableCell>
- {formatDate(revision.createdAt, "KR")}
- </TableCell>
-
- <TableCell>
- {revision.createdByName || "-"}
- </TableCell>
-
- <TableCell>
- <div className="max-w-[200px] truncate" title={revision.revisionComment}>
- {revision.revisionComment || "-"}
- </div>
- </TableCell>
-
- <TableCell>
- <Button
- variant="ghost"
- size="sm"
- className="gap-2"
- onClick={() => {
- // 파일 다운로드
- window.open(revision.filePath, '_blank')
- }}
- >
- <Download className="h-4 w-4" />
- 다운로드
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
-
- {/* 새 리비전 추가 다이얼로그 */}
- <AddRevisionDialog
- open={isAddRevisionOpen}
- onOpenChange={setIsAddRevisionOpen}
- attachmentId={attachmentId}
- currentRevision={currentRevision}
- originalFileName={originalFileName}
- onSuccess={() => {
- loadRevisions() // 리비전 목록 새로고침
- }}
- />
- </>
- )
- } \ No newline at end of file
diff --git a/lib/b-rfq/attachment/tbe-request-dialog.tsx b/lib/b-rfq/attachment/tbe-request-dialog.tsx
deleted file mode 100644
index 80b20e6f..00000000
--- a/lib/b-rfq/attachment/tbe-request-dialog.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Loader, Send } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { requestTbe } from "../service"
-
-// 첨부파일 타입
-type RfqAttachment = {
- id: number
- serialNo: string
- originalFileName: string
- attachmentType: string
- currentRevision: string
-}
-
-interface TbeRequestDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- attachments: RfqAttachment[]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function TbeRequestDialog({
- rfqId,
- attachments,
- showTrigger = true,
- onSuccess,
- ...props
-}: TbeRequestDialogProps) {
- const [isRequestPending, startRequestTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onRequest() {
- startRequestTransition(async () => {
- const attachmentIds = attachments.map(attachment => attachment.id)
- const result = await requestTbe(rfqId, attachmentIds)
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- const attachmentCount = attachments.length
- const attachmentText = attachmentCount === 1 ? "문서" : "문서들"
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={attachmentCount === 0}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`}
- </span>
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>TBE 요청을 전송하시겠습니까?</DialogTitle>
- <DialogDescription className="space-y-2">
- <div>
- 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해
- 벤더들에게 기술평가(TBE) 요청을 전송합니다.
- </div>
- <div>RFQ 상태가 "TBE started"로 변경됩니다.</div>
- {attachmentCount <= 5 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">TBE 요청 대상:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Request TBE"
- onClick={onRequest}
- disabled={isRequestPending}
- >
- {isRequestPending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- TBE 요청 전송
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={attachmentCount === 0}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`}
- </span>
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>TBE 요청을 전송하시겠습니까?</DrawerTitle>
- <DrawerDescription className="space-y-2">
- <div>
- 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해
- 벤더들에게 기술평가(TBE) 요청을 전송합니다.
- </div>
- <div>RFQ 상태가 "TBE started"로 변경됩니다.</div>
- {attachmentCount <= 5 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">TBE 요청 대상:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Request TBE"
- onClick={onRequest}
- disabled={isRequestPending}
- >
- {isRequestPending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- TBE 요청 전송
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/vendor-responses-panel.tsx b/lib/b-rfq/attachment/vendor-responses-panel.tsx
deleted file mode 100644
index 0cbe2a08..00000000
--- a/lib/b-rfq/attachment/vendor-responses-panel.tsx
+++ /dev/null
@@ -1,386 +0,0 @@
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- RefreshCw,
- Download,
- MessageSquare,
- Clock,
- CheckCircle2,
- XCircle,
- AlertCircle,
- FileText,
- Files,
- AlertTriangle
-} from "lucide-react"
-import { formatDate, formatFileSize } from "@/lib/utils"
-import { RequestRevisionDialog } from "./request-revision-dialog"
-
-interface VendorResponsesPanelProps {
- attachment: any
- responses: any[]
- isLoading: boolean
- onRefresh: () => void
-}
-
-// 파일 다운로드 핸들러
-async function handleFileDownload(filePath: string, fileName: string, fileId: number) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: "vendor",
- responseFileId: fileId.toString(),
- });
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- console.log("✅ 파일 다운로드 성공:", fileName);
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 파일 목록 컴포넌트
-function FilesList({ files }: { files: any[] }) {
- if (files.length === 0) {
- return (
- <div className="text-center py-4 text-muted-foreground text-sm">
- 업로드된 파일이 없습니다.
- </div>
- );
- }
-
- return (
- <div className="space-y-2 max-h-64 overflow-y-auto">
- {files.map((file, index) => (
- <div key={file.id} className="flex items-center justify-between p-3 border rounded-lg bg-green-50 border-green-200">
- <div className="flex items-center gap-2 flex-1 min-w-0">
- <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />
- <div className="min-w-0 flex-1">
- <div className="font-medium text-sm truncate" title={file.originalFileName}>
- {file.originalFileName}
- </div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)}
- </div>
- {file.description && (
- <div className="text-xs text-muted-foreground italic mt-1" title={file.description}>
- {file.description}
- </div>
- )}
- </div>
- </div>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => handleFileDownload(file.filePath, file.originalFileName, file.id)}
- className="flex-shrink-0 ml-2"
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- );
-}
-
-export function VendorResponsesPanel({
- attachment,
- responses,
- isLoading,
- onRefresh
-}: VendorResponsesPanelProps) {
-
- console.log(responses)
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'RESPONDED':
- return <CheckCircle2 className="h-4 w-4 text-green-600" />
- case 'NOT_RESPONDED':
- return <Clock className="h-4 w-4 text-orange-600" />
- case 'WAIVED':
- return <XCircle className="h-4 w-4 text-gray-500" />
- case 'REVISION_REQUESTED':
- return <AlertCircle className="h-4 w-4 text-yellow-600" />
- default:
- return <Clock className="h-4 w-4 text-gray-400" />
- }
- }
-
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'RESPONDED':
- return 'default'
- case 'NOT_RESPONDED':
- return 'secondary'
- case 'WAIVED':
- return 'outline'
- case 'REVISION_REQUESTED':
- return 'destructive'
- default:
- return 'secondary'
- }
- }
-
- if (isLoading) {
- return (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Skeleton className="h-6 w-48" />
- <Skeleton className="h-9 w-24" />
- </div>
- <div className="space-y-3">
- {Array.from({ length: 3 }).map((_, i) => (
- <Skeleton key={i} className="h-12 w-full" />
- ))}
- </div>
- </div>
- )
- }
-
- return (
- <div className="space-y-4">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="space-y-1">
- <h3 className="text-lg font-medium flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 벤더 응답 현황: {attachment.originalFileName}
- </h3>
- <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">
- {attachment.attachmentType}
- </Badge>
- <span>시리얼: {attachment.serialNo}</span>
- <span>등록: {formatDate(attachment.createdAt)}</span>
- {attachment.responseStats && (
- <Badge variant="secondary">
- 응답률: {attachment.responseStats.responseRate}%
- </Badge>
- )}
- </div>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="flex items-center gap-2"
- >
- <RefreshCw className="h-4 w-4" />
- 새로고침
- </Button>
- </div>
-
- {/* 테이블 */}
- {responses.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground border rounded-lg">
- 이 문서에 대한 벤더 응답 정보가 없습니다.
- </div>
- ) : (
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>벤더</TableHead>
- <TableHead>국가</TableHead>
- <TableHead>응답 상태</TableHead>
- <TableHead>리비전</TableHead>
- <TableHead>요청일</TableHead>
- <TableHead>응답일</TableHead>
- <TableHead>응답 파일</TableHead>
- <TableHead>코멘트</TableHead>
- <TableHead className="w-[100px]">액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {responses.map((response) => (
- <TableRow key={response.id}>
- <TableCell className="font-medium">
- <div>
- <div>{response.vendorName}</div>
- <div className="text-xs text-muted-foreground">
- {response.vendorCode}
- </div>
- </div>
- </TableCell>
-
- <TableCell>
- {response.vendorCountry}
- </TableCell>
-
- <TableCell>
- <div className="flex items-center gap-2">
- {getStatusIcon(response.responseStatus)}
- <Badge variant={getStatusBadgeVariant(response.responseStatus)}>
- {response.responseStatus}
- </Badge>
- </div>
- </TableCell>
-
- <TableCell>
- <div className="text-sm">
- <div>현재: {response.currentRevision}</div>
- {response.respondedRevision && (
- <div className="text-muted-foreground">
- 응답: {response.respondedRevision}
- </div>
- )}
- </div>
- </TableCell>
-
- <TableCell>
- {formatDate(response.requestedAt)}
- </TableCell>
-
- <TableCell>
- {response.respondedAt ? formatDate(response.respondedAt) : '-'}
- </TableCell>
-
- {/* 응답 파일 컬럼 */}
- <TableCell>
- {response.totalFiles > 0 ? (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="text-xs">
- {response.totalFiles}개
- </Badge>
- {response.totalFiles === 1 ? (
- // 파일이 1개면 바로 다운로드
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- onClick={() => {
- const file = response.files[0];
- handleFileDownload(file.filePath, file.originalFileName, file.id);
- }}
- title={response.latestFile?.originalFileName}
- >
- <Download className="h-4 w-4" />
- </Button>
- ) : (
- // 파일이 여러 개면 Popover로 목록 표시
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- title="파일 목록 보기"
- >
- <Files className="h-4 w-4" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-96" align="start">
- <div className="space-y-2">
- <div className="font-medium text-sm">
- 응답 파일 목록 ({response.totalFiles}개)
- </div>
- <FilesList files={response.files} />
- </div>
- </PopoverContent>
- </Popover>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </TableCell>
-
- <TableCell>
- <div className="space-y-1 max-w-[200px]">
- {/* 벤더 응답 코멘트 */}
- {response.responseComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="벤더 응답 코멘트"></div>
- <div className="text-xs text-blue-600 truncate" title={response.responseComment}>
- {response.responseComment}
- </div>
- </div>
- )}
-
- {/* 수정 요청 사유 */}
- {response.revisionRequestComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="수정 요청 사유"></div>
- <div className="text-xs text-red-600 truncate" title={response.revisionRequestComment}>
- {response.revisionRequestComment}
- </div>
- </div>
- )}
-
- {!response.responseComment && !response.revisionRequestComment && (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </div>
- </TableCell>
-
- {/* 액션 컬럼 - 수정 요청 기능으로 변경 */}
- <TableCell>
- <div className="flex items-center gap-1">
- {response.responseStatus === 'RESPONDED' && (
- <RequestRevisionDialog
- responseId={response.id}
- attachmentType={attachment.attachmentType}
- serialNo={attachment.serialNo}
- vendorName={response.vendorName}
- currentRevision={response.currentRevision}
- onSuccess={onRefresh}
- trigger={
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2"
- title="수정 요청"
- >
- <AlertTriangle className="h-3 w-3 mr-1" />
- 수정요청
- </Button>
- }
- />
- )}
-
- {response.responseStatus === 'REVISION_REQUESTED' && (
- <Badge variant="secondary" className="text-xs">
- 수정 요청됨
- </Badge>
- )}
-
- {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && (
- <span className="text-muted-foreground text-xs">-</span>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx
deleted file mode 100644
index 88d62765..00000000
--- a/lib/b-rfq/final/final-rfq-detail-columns.tsx
+++ /dev/null
@@ -1,589 +0,0 @@
-// final-rfq-detail-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { type Row } from "@tanstack/react-table"
-import {
- Ellipsis, Building, Eye, Edit,
- MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar
-} from "lucide-react"
-
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu, DropdownMenuContent, DropdownMenuItem,
- DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { FinalRfqDetailView } from "@/db/schema"
-
-// RowAction 타입 정의
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "update"
-}
-
-interface GetFinalRfqDetailColumnsProps {
- onSelectDetail?: (detail: any) => void
- setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>>
-}
-
-export function getFinalRfqDetailColumns({
- onSelectDetail,
- setRowAction
-}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] {
-
- return [
- /** ───────────── 체크박스 ───────────── */
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- /** 1. RFQ Status */
- {
- accessorKey: "finalRfqStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("finalRfqStatus") as string
- const getFinalStatusColor = (status: string) => {
- switch (status) {
- case "DRAFT": return "outline"
- case "Final RFQ Sent": return "default"
- case "Quotation Received": return "success"
- case "Vendor Selected": return "default"
- default: return "secondary"
- }
- }
- return (
- <Badge variant={getFinalStatusColor(status) as any}>
- {status}
- </Badge>
- )
- },
- size: 120
- },
-
- /** 2. RFQ No. */
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.getValue("rfqCode") as string}
- </div>
- ),
- size: 120,
- },
-
- /** 3. Rev. */
- {
- accessorKey: "returnRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Rev." />
- ),
- cell: ({ row }) => {
- const revision = row.getValue("returnRevision") as number
- return revision > 0 ? (
- <Badge variant="outline">
- Rev. {revision}
- </Badge>
- ) : (
- <Badge variant="outline">
- Rev. 0
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 4. Vendor Code */
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.original.vendorCode}
- </div>
- ),
- size: 100,
- },
-
- /** 5. Vendor Name */
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.original.vendorName}
- </div>
- ),
- size: 150,
- },
-
- /** 6. 업체분류 */
- {
- id: "vendorClassification",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체분류" />
- ),
- cell: ({ row }) => {
- const vendorCode = row.original.vendorCode as string
- return vendorCode ? (
- <Badge variant="success" className="text-xs">
- 정규업체
- </Badge>
- ) : (
- <Badge variant="secondary" className="text-xs">
- 잠재업체
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 7. CP 현황 */
- {
- accessorKey: "cpRequestYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CP 현황" />
- ),
- cell: ({ row }) => {
- const cpRequest = row.getValue("cpRequestYn") as boolean
- return cpRequest ? (
- <Badge variant="success" className="text-xs">
- 신청
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 미신청
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 8. GTC현황 */
- {
- id: "gtcStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC현황" />
- ),
- cell: ({ row }) => {
- const gtc = row.original.gtc as string
- const gtcValidDate = row.original.gtcValidDate as string
- const prjectGtcYn = row.original.prjectGtcYn as boolean
-
- if (prjectGtcYn || gtc) {
- return (
- <div className="space-y-1">
- <Badge variant="success" className="text-xs">
- 보유
- </Badge>
- {gtcValidDate && (
- <div className="text-xs text-muted-foreground">
- {gtcValidDate}
- </div>
- )}
- </div>
- )
- }
- return (
- <Badge variant="outline" className="text-xs">
- 미보유
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 9. TBE 결과 (스키마에 없어서 placeholder) */
- {
- id: "tbeResult",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
- ),
- cell: ({ row }) => {
- // TODO: TBE 결과 로직 구현 필요
- return (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 80,
- },
-
- /** 10. 최종 선정 */
- {
- id: "finalSelection",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 선정" />
- ),
- cell: ({ row }) => {
- const status = row.original.finalRfqStatus as string
- return status === "Vendor Selected" ? (
- <Badge variant="success" className="text-xs">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- 선정
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 80,
- },
-
- /** 11. Currency */
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Currency" />
- ),
- cell: ({ row }) => {
- const currency = row.getValue("currency") as string
- return currency ? (
- <Badge variant="outline" className="text-xs">
- {/* <DollarSign className="h-3 w-3 mr-1" /> */}
- {currency}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- /** 12. Terms of Payment */
- {
- accessorKey: "paymentTermsCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Terms of Payment" />
- ),
- cell: ({ row }) => {
- const paymentTermsCode = row.getValue("paymentTermsCode") as string
- return paymentTermsCode ? (
- <Badge variant="secondary" className="text-xs">
- {paymentTermsCode}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 13. Payment Desc. */
- {
- accessorKey: "paymentTermsDescription",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Payment Desc." />
- ),
- cell: ({ row }) => {
- const description = row.getValue("paymentTermsDescription") as string
- return description ? (
- <div className="text-xs max-w-[150px] truncate" title={description}>
- {description}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** 14. TAX */
- {
- accessorKey: "taxCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TAX" />
- ),
- cell: ({ row }) => {
- const taxCode = row.getValue("taxCode") as string
- return taxCode ? (
- <Badge variant="outline" className="text-xs">
- {taxCode}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- /** 15. Delivery Date* */
- {
- accessorKey: "deliveryDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Delivery Date*" />
- ),
- cell: ({ row }) => {
- const deliveryDate = row.getValue("deliveryDate") as Date
- return deliveryDate ? (
- <div className="text-sm">
- {formatDate(deliveryDate, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 16. Country */
- {
- accessorKey: "vendorCountry",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Country" />
- ),
- cell: ({ row }) => {
- const country = row.getValue("vendorCountry") as string
- const countryDisplay = country === "KR" ? "D" : "F"
- return (
- <Badge variant="outline" className="text-xs">
- {countryDisplay}
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 17. Place of Shipping */
- {
- accessorKey: "placeOfShipping",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Place of Shipping" />
- ),
- cell: ({ row }) => {
- const placeOfShipping = row.getValue("placeOfShipping") as string
- return placeOfShipping ? (
- <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}>
- {placeOfShipping}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 18. Place of Destination */
- {
- accessorKey: "placeOfDestination",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Place of Destination" />
- ),
- cell: ({ row }) => {
- const placeOfDestination = row.getValue("placeOfDestination") as string
- return placeOfDestination ? (
- <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}>
- {placeOfDestination}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 19. 초도 여부* */
- {
- accessorKey: "firsttimeYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="초도 여부*" />
- ),
- cell: ({ row }) => {
- const firsttime = row.getValue("firsttimeYn") as boolean
- return firsttime ? (
- <Badge variant="success" className="text-xs">
- 초도
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 재구매
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 20. 연동제 적용* */
- {
- accessorKey: "materialPriceRelatedYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="연동제 적용*" />
- ),
- cell: ({ row }) => {
- const materialPrice = row.getValue("materialPriceRelatedYn") as boolean
- return materialPrice ? (
- <Badge variant="success" className="text-xs">
- 적용
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 미적용
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 21. Business Size */
- {
- id: "businessSizeDisplay",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Business Size" />
- ),
- cell: ({ row }) => {
- const businessSize = row.original.vendorBusinessSize as string
- return businessSize ? (
- <Badge variant="outline" className="text-xs">
- {businessSize}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- /** 22. 최종 Update일 */
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 Update일" />
- ),
- cell: ({ row }) => {
- const updated = row.getValue("updatedAt") as Date
- return updated ? (
- <div className="text-sm">
- {formatDate(updated, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */
- {
- id: "updatedByUser",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" />
- ),
- cell: ({ row }) => {
- // TODO: updatedBy 사용자 정보 조인 필요
- return (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 120,
- },
-
- /** 24. Vendor 설명 */
- {
- accessorKey: "vendorRemark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor 설명" />
- ),
- cell: ({ row }) => {
- const vendorRemark = row.getValue("vendorRemark") as string
- return vendorRemark ? (
- <div className="text-xs max-w-[150px] truncate" title={vendorRemark}>
- {vendorRemark}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** 25. 비고 */
- {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => {
- const remark = row.getValue("remark") as string
- return remark ? (
- <div className="text-xs max-w-[150px] truncate" title={remark}>
- {remark}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
- <DropdownMenuItem>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 견적 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- {setRowAction && (
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- },
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx
deleted file mode 100644
index 8ae42e7e..00000000
--- a/lib/b-rfq/final/final-rfq-detail-table.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션
-import {
- getFinalRfqDetailColumns,
- type DataTableRowAction
-} from "./final-rfq-detail-columns"
-import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions"
-import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface FinalRfqDetailTableProps {
- promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>>
- rfqId?: number
-}
-
-export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 상세 정보
- const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
-
- // Row action 상태 (update만)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null)
-
- const columns = React.useMemo(
- () => getFinalRfqDetailColumns({
- onSelectDetail: setSelectedDetail,
- setRowAction: setRowAction
- }),
- []
- )
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- placeholder: "RFQ 코드로 검색...",
- },
- {
- id: "vendorName",
- label: "벤더명",
- placeholder: "벤더명으로 검색...",
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- options: [
- { label: "Draft", value: "DRAFT", count: 0 },
- { label: "문서 접수", value: "Doc. Received", count: 0 },
- { label: "담당자 배정", value: "PIC Assigned", count: 0 },
- { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
- { label: "TBE 시작", value: "TBE started", count: 0 },
- { label: "TBE 완료", value: "TBE finished", count: 0 },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "finalRfqStatus",
- label: "최종 RFQ 상태",
- options: [
- { label: "초안", value: "DRAFT", count: 0 },
- { label: "발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- options: [
- { label: "한국", value: "KR", count: 0 },
- { label: "중국", value: "CN", count: 0 },
- { label: "일본", value: "JP", count: 0 },
- { label: "미국", value: "US", count: 0 },
- { label: "독일", value: "DE", count: 0 },
- ],
- },
- {
- id: "currency",
- label: "통화",
- options: [
- { label: "USD", value: "USD", count: 0 },
- { label: "EUR", value: "EUR", count: 0 },
- { label: "KRW", value: "KRW", count: 0 },
- { label: "JPY", value: "JPY", count: 0 },
- { label: "CNY", value: "CNY", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- type: "text",
- },
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- type: "multi-select",
- options: [
- { label: "한국", value: "KR" },
- { label: "중국", value: "CN" },
- { label: "일본", value: "JP" },
- { label: "미국", value: "US" },
- { label: "독일", value: "DE" },
- ],
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- type: "multi-select",
- options: [
- { label: "Draft", value: "DRAFT" },
- { label: "문서 접수", value: "Doc. Received" },
- { label: "담당자 배정", value: "PIC Assigned" },
- { label: "문서 확정", value: "Doc. Confirmed" },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
- { label: "TBE 시작", value: "TBE started" },
- { label: "TBE 완료", value: "TBE finished" },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "finalRfqStatus",
- label: "최종 RFQ 상태",
- type: "multi-select",
- options: [
- { label: "초안", value: "DRAFT" },
- { label: "발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "vendorBusinessSize",
- label: "벤더 규모",
- type: "multi-select",
- options: [
- { label: "대기업", value: "LARGE" },
- { label: "중기업", value: "MEDIUM" },
- { label: "소기업", value: "SMALL" },
- { label: "스타트업", value: "STARTUP" },
- ],
- },
- {
- id: "incotermsCode",
- label: "Incoterms",
- type: "text",
- },
- {
- id: "paymentTermsCode",
- label: "Payment Terms",
- type: "text",
- },
- {
- id: "currency",
- label: "통화",
- type: "multi-select",
- options: [
- { label: "USD", value: "USD" },
- { label: "EUR", value: "EUR" },
- { label: "KRW", value: "KRW" },
- { label: "JPY", value: "JPY" },
- { label: "CNY", value: "CNY" },
- ],
- },
- {
- id: "dueDate",
- label: "마감일",
- type: "date",
- },
- {
- id: "validDate",
- label: "유효일",
- type: "date",
- },
- {
- id: "deliveryDate",
- label: "납기일",
- type: "date",
- },
- {
- id: "shortList",
- label: "Short List",
- type: "boolean",
- },
- {
- id: "returnYn",
- label: "Return 여부",
- type: "boolean",
- },
- {
- id: "cpRequestYn",
- label: "CP Request 여부",
- type: "boolean",
- },
- {
- id: "prjectGtcYn",
- label: "Project GTC 여부",
- type: "boolean",
- },
- {
- id: "firsttimeYn",
- label: "First Time 여부",
- type: "boolean",
- },
- {
- id: "materialPriceRelatedYn",
- label: "Material Price Related 여부",
- type: "boolean",
- },
- {
- id: "classification",
- label: "분류",
- type: "text",
- },
- {
- id: "sparepart",
- label: "예비부품",
- type: "text",
- },
- {
- id: "createdAt",
- label: "등록일",
- type: "date",
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.finalRfqId ? originalRow.finalRfqId.toString() : "1",
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <div className="space-y-6">
- {/* 메인 테이블 */}
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* Update Sheet */}
- <UpdateFinalRfqSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- finalRfq={rowAction?.type === "update" ? rowAction.row.original : null}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
deleted file mode 100644
index d8be4f7b..00000000
--- a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { Button } from "@/components/ui/button"
-import {
- Mail,
- CheckCircle2,
- Loader,
- Award,
- RefreshCw
-} from "lucide-react"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface FinalRfqDetailTableToolbarActionsProps {
- table: Table<FinalRfqDetailView>
- rfqId?: number
- onRefresh?: () => void // 데이터 새로고침 콜백
-}
-
-export function FinalRfqDetailTableToolbarActions({
- table,
- rfqId,
- onRefresh
-}: FinalRfqDetailTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedDetails = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- // 상태 관리
- const [isEmailSending, setIsEmailSending] = React.useState(false)
- const [isSelecting, setIsSelecting] = React.useState(false)
-
- // RFQ 발송 핸들러 (로직 없음)
- const handleBulkRfqSend = async () => {
- if (selectedCount === 0) {
- toast.error("발송할 RFQ를 선택해주세요.")
- return
- }
-
- setIsEmailSending(true)
-
- try {
- // TODO: 실제 RFQ 발송 로직 구현
- await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이
-
- toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`)
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- } catch (error) {
- console.error("RFQ sending error:", error)
- toast.error("최종 RFQ 발송 중 오류가 발생했습니다.")
- } finally {
- setIsEmailSending(false)
- }
- }
-
- // 최종 선정 핸들러 (로직 없음)
- const handleFinalSelection = async () => {
- if (selectedCount === 0) {
- toast.error("최종 선정할 벤더를 선택해주세요.")
- return
- }
-
- if (selectedCount > 1) {
- toast.error("최종 선정은 1개의 벤더만 가능합니다.")
- return
- }
-
- setIsSelecting(true)
-
- try {
- // TODO: 실제 최종 선정 로직 구현
- await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이
-
- const selectedVendor = selectedDetails[0]
- toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`)
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- // 계약서 페이지로 이동 (필요시)
- if (rfqId) {
- setTimeout(() => {
- toast.info("계약서 작성 페이지로 이동합니다.")
- // router.push(`/evcp/contracts/${rfqId}`)
- }, 1500)
- }
-
- } catch (error) {
- console.error("Final selection error:", error)
- toast.error("최종 선정 중 오류가 발생했습니다.")
- } finally {
- setIsSelecting(false)
- }
- }
-
- // 발송 가능한 RFQ 필터링 (DRAFT 상태)
- const sendableRfqs = selectedDetails.filter(
- detail => detail.finalRfqStatus === "DRAFT"
- )
- const sendableCount = sendableRfqs.length
-
- // 선정 가능한 벤더 필터링 (견적 접수 상태)
- const selectableVendors = selectedDetails.filter(
- detail => detail.finalRfqStatus === "Quotation Received"
- )
- const selectableCount = selectableVendors.length
-
- // 전체 벤더 중 견적 접수 완료된 벤더 수
- const allVendors = table.getRowModel().rows.map(row => row.original)
- const quotationReceivedCount = allVendors.filter(
- vendor => vendor.finalRfqStatus === "Quotation Received"
- ).length
-
- return (
- <div className="flex items-center gap-2">
- {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
- {selectedCount > 0 && (
- <>
- {/* RFQ 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkRfqSend}
- className="h-8"
- disabled={isEmailSending || sendableCount === 0}
- title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`}
- >
- {isEmailSending ? (
- <Loader className="mr-2 h-4 w-4 animate-spin" />
- ) : (
- <Mail className="mr-2 h-4 w-4" />
- )}
- 최종 RFQ 발송 ({sendableCount}/{selectedCount})
- </Button>
-
- {/* 최종 선정 버튼 */}
- <Button
- variant="default"
- size="sm"
- onClick={handleFinalSelection}
- className="h-8"
- disabled={isSelecting || selectedCount !== 1 || selectableCount === 0}
- title={
- selectedCount !== 1
- ? "최종 선정은 1개의 벤더만 선택해주세요"
- : selectableCount === 0
- ? "견적 접수가 완료된 벤더만 선정 가능합니다"
- : "선택된 벤더를 최종 선정"
- }
- >
- {isSelecting ? (
- <Loader className="mr-2 h-4 w-4 animate-spin" />
- ) : (
- <Award className="mr-2 h-4 w-4" />
- )}
- 최종 선정
- </Button>
- </>
- )}
-
- {/* 정보 표시 (선택이 없을 때) */}
- {selectedCount === 0 && quotationReceivedCount > 0 && (
- <div className="text-sm text-muted-foreground">
- 견적 접수 완료: {quotationReceivedCount}개 벤더
- </div>
- )}
-
- {/* 새로고침 버튼 */}
- {onRefresh && (
- <Button
- variant="ghost"
- size="sm"
- onClick={onRefresh}
- className="h-8"
- title="데이터 새로고침"
- >
- <RefreshCw className="h-4 w-4" />
- </Button>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx
deleted file mode 100644
index 65e23a92..00000000
--- a/lib/b-rfq/final/update-final-rfq-sheet.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface UpdateFinalRfqSheetProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- finalRfq: FinalRfqDetailView | null
-}
-
-export function UpdateFinalRfqSheet({
- open,
- onOpenChange,
- finalRfq
-}: UpdateFinalRfqSheetProps) {
- return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:max-w-md">
- <SheetHeader>
- <SheetTitle>최종 RFQ 수정</SheetTitle>
- <SheetDescription>
- 최종 RFQ 정보를 수정합니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="py-6">
- {finalRfq && (
- <div className="space-y-4">
- <div>
- <h4 className="font-medium">RFQ 정보</h4>
- <p className="text-sm text-muted-foreground">
- RFQ Code: {finalRfq.rfqCode}
- </p>
- <p className="text-sm text-muted-foreground">
- 벤더: {finalRfq.vendorName}
- </p>
- <p className="text-sm text-muted-foreground">
- 상태: {finalRfq.finalRfqStatus}
- </p>
- </div>
-
- {/* TODO: 실제 업데이트 폼 구현 */}
- <div className="text-center text-muted-foreground">
- 업데이트 폼이 여기에 구현됩니다.
- </div>
- </div>
- )}
- </div>
-
- <div className="flex justify-end gap-2">
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={() => onOpenChange(false)}>
- 저장
- </Button>
- </div>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
deleted file mode 100644
index 58a091ac..00000000
--- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
+++ /dev/null
@@ -1,584 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Checkbox } from "@/components/ui/checkbox"
-import { cn, formatDate } from "@/lib/utils"
-import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service"
-import { Calendar } from "@/components/ui/calendar"
-import { InitialRfqDetailView } from "@/db/schema"
-
-// Initial RFQ 추가 폼 스키마
-const addInitialRfqSchema = z.object({
- vendorId: z.number({
- required_error: "벤더를 선택해주세요.",
- }),
- initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], {
- required_error: "초기 RFQ 상태를 선택해주세요.",
- }).default("DRAFT"),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- validDate: z.date().optional(),
- incotermsCode: z.string().optional(),
- gtc: z.string().optional(),
- gtcValidDate: z.string().optional(),
- classification: z.string().optional(),
- sparepart: z.string().optional(),
- shortList: z.boolean().default(false),
- returnYn: z.boolean().default(false),
- cpRequestYn: z.boolean().default(false),
- prjectGtcYn: z.boolean().default(false),
- returnRevision: z.number().default(0),
-})
-
-export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-interface Incoterm {
- id: number
- code: string
- description: string
-}
-
-interface AddInitialRfqDialogProps {
- rfqId: number
- onSuccess?: () => void
- defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값
-}
-
-export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
- const [incotermsLoading, setIncotermsLoading] = React.useState(false)
- const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
-
- // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값)
- const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => {
- if (defaultValues) {
- return {
- vendorId: defaultValues.vendorId,
- initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작
- dueDate: defaultValues.dueDate || new Date(),
- validDate: defaultValues.validDate,
- incotermsCode: defaultValues.incotermsCode || "",
- classification: defaultValues.classification || "",
- sparepart: defaultValues.sparepart || "",
- shortList: false, // 새로 추가할 때는 기본적으로 false
- returnYn: false,
- cpRequestYn: defaultValues.cpRequestYn || false,
- prjectGtcYn: defaultValues.prjectGtcYn || false,
- returnRevision: 0,
- }
- }
-
- return {
- initialRfqStatus: "DRAFT",
- shortList: false,
- returnYn: false,
- cpRequestYn: false,
- prjectGtcYn: false,
- returnRevision: 0,
- }
- }, [defaultValues])
-
- const form = useForm<AddInitialRfqFormData>({
- resolver: zodResolver(addInitialRfqSchema),
- defaultValues: getDefaultFormValues(),
- })
-
- // 벤더 목록 로드
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- // Incoterms 목록 로드
- const loadIncoterms = React.useCallback(async () => {
- setIncotermsLoading(true)
- try {
- const incotermsList = await getIncotermsForSelection()
- setIncoterms(incotermsList)
- } catch (error) {
- console.error("Failed to load incoterms:", error)
- toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
- } finally {
- setIncotermsLoading(false)
- }
- }, [])
-
- // 다이얼로그 열릴 때 실행
- React.useEffect(() => {
- if (open) {
- // 폼을 기본값으로 리셋
- form.reset(getDefaultFormValues())
-
- // 데이터 로드
- if (vendors.length === 0) {
- loadVendors()
- }
- if (incoterms.length === 0) {
- loadIncoterms()
- }
- }
- }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues])
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset(getDefaultFormValues())
- }
- setOpen(newOpen)
- }
-
- // 폼 제출
- const onSubmit = async (data: AddInitialRfqFormData) => {
- setIsSubmitting(true)
-
- try {
- const result = await addInitialRfqRecord({
- ...data,
- rfqId,
- })
-
- if (result.success) {
- toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.")
- form.reset(getDefaultFormValues())
- handleOpenChange(false)
- onSuccess?.()
- } else {
- toast.error(result.message || "초기 RFQ 추가에 실패했습니다.")
- }
-
- } catch (error) {
- console.error("Submit error:", error)
- toast.error("초기 RFQ 추가 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId"))
- const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
-
- // 기본값이 있을 때 버튼 텍스트 변경
- const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가"
- const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가"
- const dialogDescription = defaultValues
- ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다."
- : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다."
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">{buttonText}</span>
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>{dialogTitle}</DialogTitle>
- <DialogDescription>
- {dialogDescription}
- {defaultValues && (
- <div className="mt-2 p-2 bg-muted rounded-md text-sm">
- <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode})
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>벤더 선택 *</FormLabel>
- <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorSearchOpen}
- className="justify-between"
- disabled={vendorsLoading}
- >
- {selectedVendor ? (
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4" />
- <span className="truncate">
- {selectedVendor.vendorName} ({selectedVendor.vendorCode})
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="벤더명 또는 코드로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorName} ${vendor.vendorCode}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <Building className="h-4 w-4" />
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {vendor.vendorName}
- </div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode} • {vendor.country} • {vendor.taxId}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 날짜 필드들 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 마감일 *</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value, "KR")
- ) : (
- <span>견적 마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="validDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 유효일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value, "KR")
- ) : (
- <span>견적 유효일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* Incoterms 선택 */}
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>Incoterms</FormLabel>
- <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={incotermsSearchOpen}
- className="justify-between"
- disabled={incotermsLoading}
- >
- {selectedIncoterm ? (
- <div className="flex items-center gap-2">
- <span className="truncate">
- {selectedIncoterm.code} - {selectedIncoterm.description}
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="코드 또는 내용으로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {incoterms.map((incoterm) => (
- <CommandItem
- key={incoterm.id}
- value={`${incoterm.code} ${incoterm.description}`}
- onSelect={() => {
- field.onChange(incoterm.code)
- setIncotermsSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {incoterm.code} - {incoterm.description}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- incoterm.code === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 옵션 체크박스 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="cpRequestYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>CP 요청</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="prjectGtcYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>Project용 GTC 사용</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </div>
-
- {/* 분류 정보 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="classification"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선급</FormLabel>
- <FormControl>
- <Input placeholder="선급" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="sparepart"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Spare part</FormLabel>
- <FormControl>
- <Input placeholder="O1, O2" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "추가 중..." : "추가"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
deleted file mode 100644
index b5a231b7..00000000
--- a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { InitialRfqDetailView } from "@/db/schema"
-import { removeInitialRfqs } from "../service"
-
-interface DeleteInitialRfqDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- initialRfqs: Row<InitialRfqDetailView>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteInitialRfqDialog({
- initialRfqs,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteInitialRfqDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startDeleteTransition(async () => {
- const { error } = await removeInitialRfqs({
- ids: initialRfqs.map((rfq) => rfq.id),
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("초기 RFQ가 삭제되었습니다")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({initialRfqs.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{initialRfqs.length}개</span>의
- 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({initialRfqs.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{initialRfqs.length}개</span>의
- 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
deleted file mode 100644
index 2d9c3a68..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
+++ /dev/null
@@ -1,446 +0,0 @@
-// initial-rfq-detail-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { type Row } from "@tanstack/react-table"
-import {
- Ellipsis, Building, Eye, Edit, Trash,
- MessageSquare, Settings, CheckCircle2, XCircle
-} from "lucide-react"
-
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu, DropdownMenuContent, DropdownMenuItem,
- DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { InitialRfqDetailView } from "@/db/schema"
-
-
-// RowAction 타입 정의
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "update" | "delete"
-}
-
-interface GetInitialRfqDetailColumnsProps {
- onSelectDetail?: (detail: any) => void
- setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>>
-}
-
-export function getInitialRfqDetailColumns({
- onSelectDetail,
- setRowAction
-}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] {
-
- return [
- /** ───────────── 체크박스 ───────────── */
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- /** ───────────── RFQ 정보 ───────────── */
- {
- accessorKey: "initialRfqStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("initialRfqStatus") as string
- const getInitialStatusColor = (status: string) => {
- switch (status) {
- case "DRAFT": return "outline"
- case "Init. RFQ Sent": return "default"
- case "Init. RFQ Answered": return "success"
- case "S/L Decline": return "destructive"
- default: return "secondary"
- }
- }
- return (
- <Badge variant={getInitialStatusColor(status) as any}>
- {status}
- </Badge>
- )
- },
- size: 120
- },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- {row.getValue("rfqCode") as string}
- </div>
- ),
- size: 120,
- },
- {
- accessorKey: "rfqRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- Rev. {row.getValue("rfqRevision") as number}
- </div>
- ),
- size: 120,
- },
-
- /** ───────────── 벤더 정보 ───────────── */
- {
- id: "vendorInfo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 정보" />
- ),
- cell: ({ row }) => {
- const vendorName = row.original.vendorName as string
- const vendorCode = row.original.vendorCode as string
- const vendorType = row.original.vendorCategory as string
- const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F"
- const businessSize = row.original.vendorBusinessSize as string
-
- return (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <div className="font-medium">{vendorName}</div>
- </div>
- <div className="text-sm text-muted-foreground">
- {vendorCode} • {vendorType} • {vendorCountry}
- </div>
- {businessSize && (
- <Badge variant="outline" className="text-xs">
- {businessSize}
- </Badge>
- )}
- </div>
- )
- },
- size: 200,
- },
-
- {
- accessorKey: "cpRequestYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CP" />
- ),
- cell: ({ row }) => {
- const cpRequest = row.getValue("cpRequestYn") as boolean
- return cpRequest ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 60,
- },
- {
- accessorKey: "prjectGtcYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Project GTC" />
- ),
- cell: ({ row }) => {
- const projectGtc = row.getValue("prjectGtcYn") as boolean
- return projectGtc ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 100,
- },
- {
- accessorKey: "gtcYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC" />
- ),
- cell: ({ row }) => {
- const gtc = row.getValue("gtcYn") as boolean
- return gtc ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 60,
- },
- {
- accessorKey: "gtcValidDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC 유효일" />
- ),
- cell: ({ row }) => {
- const gtcValidDate = row.getValue("gtcValidDate") as string
- return gtcValidDate ? (
- <div className="text-sm">
- {gtcValidDate}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- {
- accessorKey: "classification",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선급" />
- ),
- cell: ({ row }) => {
- const classification = row.getValue("classification") as string
- return classification ? (
- <div className="text-sm font-medium max-w-[120px] truncate" title={classification}>
- {classification}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- {
- accessorKey: "sparepart",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Spare Part" />
- ),
- cell: ({ row }) => {
- const sparepart = row.getValue("sparepart") as string
- return sparepart ? (
- <Badge variant="outline" className="text-xs">
- {sparepart}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- {
- id: "incoterms",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Incoterms" />
- ),
- cell: ({ row }) => {
- const code = row.original.incotermsCode as string
- const description = row.original.incotermsDescription as string
-
- return code ? (
- <div className="space-y-1">
- <Badge variant="outline">{code}</Badge>
- {description && (
- <div className="text-xs text-muted-foreground max-w-[150px] truncate" title={description}>
- {description}
- </div>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** ───────────── 날짜 정보 ───────────── */
- {
- accessorKey: "validDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효일" />
- ),
- cell: ({ row }) => {
- const validDate = row.getValue("validDate") as Date
- return validDate ? (
- <div className="text-sm">
- {formatDate(validDate, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date
- const isOverdue = dueDate && new Date(dueDate) < new Date()
-
- return dueDate ? (
- <div className={`${isOverdue ? 'text-red-600' : ''}`}>
- <div className="font-medium">{formatDate(dueDate, "KR")}</div>
- {isOverdue && (
- <div className="text-xs text-red-600">지연</div>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
- {
- accessorKey: "returnYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" />
- ),
- cell: ({ row }) => {
- const returnFlag = row.getValue("returnYn") as boolean
- return returnFlag ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 70,
- },
- {
- accessorKey: "returnRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="회신 리비전" />
- ),
- cell: ({ row }) => {
- const revision = row.getValue("returnRevision") as number
- return revision > 0 ? (
- <Badge variant="outline">
- Rev. {revision}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- {
- accessorKey: "shortList",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Short List" />
- ),
- cell: ({ row }) => {
- const shortList = row.getValue("shortList") as boolean
- return shortList ? (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 90,
- },
-
- /** ───────────── 등록/수정 정보 ───────────── */
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ row }) => {
- const created = row.getValue("createdAt") as Date
- const updated = row.original.updatedAt as Date
-
- return (
- <div className="space-y-1">
- <div className="text-sm">{formatDate(created, "KR")}</div>
- {updated && new Date(updated) > new Date(created) && (
- <div className="text-xs text-blue-600">
- 수정: {formatDate(updated, "KR")}
- </div>
- )}
- </div>
- )
- },
- size: 120,
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
- <DropdownMenuItem>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 응답 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- {setRowAction && (
- <>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- <Trash className="mr-2 h-4 w-4" />
- 삭제
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </>
- )}
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- },
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
deleted file mode 100644
index 5ea6b0bf..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션
-import {
- getInitialRfqDetailColumns,
- type DataTableRowAction
-} from "./initial-rfq-detail-columns"
-import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions"
-import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
-import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet"
-import { InitialRfqDetailView } from "@/db/schema"
-
-interface InitialRfqDetailTableProps {
- promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>>
- rfqId?: number
-}
-
-export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 상세 정보
- const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
-
- // Row action 상태 (update/delete)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | null>(null)
-
- const columns = React.useMemo(
- () => getInitialRfqDetailColumns({
- onSelectDetail: setSelectedDetail,
- setRowAction: setRowAction
- }),
- []
- )
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- placeholder: "RFQ 코드로 검색...",
- },
- {
- id: "vendorName",
- label: "벤더명",
- placeholder: "벤더명으로 검색...",
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- options: [
- { label: "Draft", value: "DRAFT", count: 0 },
- { label: "문서 접수", value: "Doc. Received", count: 0 },
- { label: "담당자 배정", value: "PIC Assigned", count: 0 },
- { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
- { label: "TBE 시작", value: "TBE started", count: 0 },
- { label: "TBE 완료", value: "TBE finished", count: 0 },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "initialRfqStatus",
- label: "초기 RFQ 상태",
- options: [
- { label: "초안", value: "DRAFT", count: 0 },
- { label: "발송", value: "Init. RFQ Sent", count: 0 },
- { label: "응답", value: "Init. RFQ Answered", count: 0 },
- { label: "거절", value: "S/L Decline", count: 0 },
- ],
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- options: [
- { label: "한국", value: "KR", count: 0 },
- { label: "중국", value: "CN", count: 0 },
- { label: "일본", value: "JP", count: 0 },
- { label: "미국", value: "US", count: 0 },
- { label: "독일", value: "DE", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- type: "text",
- },
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- type: "multi-select",
- options: [
- { label: "한국", value: "KR" },
- { label: "중국", value: "CN" },
- { label: "일본", value: "JP" },
- { label: "미국", value: "US" },
- { label: "독일", value: "DE" },
- ],
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- type: "multi-select",
- options: [
- { label: "Draft", value: "DRAFT" },
- { label: "문서 접수", value: "Doc. Received" },
- { label: "담당자 배정", value: "PIC Assigned" },
- { label: "문서 확정", value: "Doc. Confirmed" },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
- { label: "TBE 시작", value: "TBE started" },
- { label: "TBE 완료", value: "TBE finished" },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "initialRfqStatus",
- label: "초기 RFQ 상태",
- type: "multi-select",
- options: [
- { label: "초안", value: "DRAFT" },
- { label: "발송", value: "Init. RFQ Sent" },
- { label: "응답", value: "Init. RFQ Answered" },
- { label: "거절", value: "S/L Decline" },
- ],
- },
- {
- id: "vendorBusinessSize",
- label: "벤더 규모",
- type: "multi-select",
- options: [
- { label: "대기업", value: "LARGE" },
- { label: "중기업", value: "MEDIUM" },
- { label: "소기업", value: "SMALL" },
- { label: "스타트업", value: "STARTUP" },
- ],
- },
- {
- id: "incotermsCode",
- label: "Incoterms",
- type: "text",
- },
- {
- id: "dueDate",
- label: "마감일",
- type: "date",
- },
- {
- id: "validDate",
- label: "유효일",
- type: "date",
- },
- {
- id: "shortList",
- label: "Short List",
- type: "boolean",
- },
- {
- id: "returnYn",
- label: "Return 여부",
- type: "boolean",
- },
- {
- id: "cpRequestYn",
- label: "CP Request 여부",
- type: "boolean",
- },
- {
- id: "prjectGtcYn",
- label: "Project GTC 여부",
- type: "boolean",
- },
- {
- id: "classification",
- label: "분류",
- type: "text",
- },
- {
- id: "sparepart",
- label: "예비부품",
- type: "text",
- },
- {
- id: "createdAt",
- label: "등록일",
- type: "date",
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.initialRfqId ? originalRow.initialRfqId.toString():"1",
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <div className="space-y-6">
- {/* 메인 테이블 */}
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <InitialRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* Update Sheet */}
- <UpdateInitialRfqSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- initialRfq={rowAction?.type === "update" ? rowAction.row.original : null}
- />
-
- {/* Delete Dialog */}
- <DeleteInitialRfqDialog
- open={rowAction?.type === "delete"}
- onOpenChange={() => setRowAction(null)}
- initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null)
- // 테이블 리프레시는 revalidatePath로 자동 처리됨
- }}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
deleted file mode 100644
index c26bda28..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { Button } from "@/components/ui/button"
-import {
- Download,
- Mail,
- RefreshCw,
- Settings,
- Trash2,
- FileText,
- CheckCircle2,
- Loader
-} from "lucide-react"
-import { AddInitialRfqDialog } from "./add-initial-rfq-dialog"
-import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
-import { ShortListConfirmDialog } from "./short-list-confirm-dialog"
-import { InitialRfqDetailView } from "@/db/schema"
-import { sendBulkInitialRfqEmails } from "../service"
-
-interface InitialRfqDetailTableToolbarActionsProps {
- table: Table<InitialRfqDetailView>
- rfqId?: number
- onRefresh?: () => void // 데이터 새로고침 콜백
-}
-
-export function InitialRfqDetailTableToolbarActions({
- table,
- rfqId,
- onRefresh
-}: InitialRfqDetailTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedDetails = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- // 상태 관리
- const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
- const [showShortListDialog, setShowShortListDialog] = React.useState(false)
- const [isEmailSending, setIsEmailSending] = React.useState(false)
-
- // 전체 벤더 리스트 가져오기 (ShortList 확정용)
- const allVendors = table.getRowModel().rows.map(row => row.original)
-
-const handleBulkEmail = async () => {
- if (selectedCount === 0) return
-
- setIsEmailSending(true)
-
- try {
- const initialRfqIds = selectedDetails
- .map(detail => detail.initialRfqId)
- .filter((id): id is number => id !== null);
-
- if (initialRfqIds.length === 0) {
- toast.error("유효한 RFQ ID가 없습니다.")
- return
- }
-
- const result = await sendBulkInitialRfqEmails({
- initialRfqIds,
- language: "en" // 기본 영어, 필요시 사용자 설정으로 변경
- })
-
- if (result.success) {
- toast.success(result.message)
-
- // 에러가 있다면 별도 알림
- if (result.errors && result.errors.length > 0) {
- setTimeout(() => {
- toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`)
- }, 1000)
- }
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
- } else {
- toast.error(result.message || "RFQ 발송에 실패했습니다.")
- }
-
- } catch (error) {
- console.error("Email sending error:", error)
- toast.error("RFQ 발송 중 오류가 발생했습니다.")
- } finally {
- setIsEmailSending(false)
- }
- }
-
- const handleBulkDelete = () => {
- // DRAFT가 아닌 상태의 RFQ 확인
- const nonDraftRfqs = selectedDetails.filter(
- detail => detail.initialRfqStatus !== "DRAFT"
- )
-
- if (nonDraftRfqs.length > 0) {
- const statusMessages = {
- "Init. RFQ Sent": "이미 발송된",
- "S/L Decline": "Short List 거절 처리된",
- "Init. RFQ Answered": "답변 완료된"
- }
-
- const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))]
- const statusText = nonDraftStatuses
- .map(status => statusMessages[status as keyof typeof statusMessages] || status)
- .join(", ")
-
- toast.error(
- `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.`
- )
- return
- }
-
- setShowDeleteDialog(true)
- }
-
- // S/L 확정 버튼 클릭
- const handleSlConfirm = () => {
- if (!rfqId || allVendors.length === 0) {
- toast.error("S/L 확정할 벤더가 없습니다.")
- return
- }
-
- // 진행 가능한 상태 확인
- const validVendors = allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- )
-
- if (validVendors.length === 0) {
- toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)")
- return
- }
-
- setShowShortListDialog(true)
- }
-
- // 초기 RFQ 추가 성공 시 처리
- const handleAddSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- } else {
- // fallback으로 페이지 새로고침
- setTimeout(() => {
- window.location.reload()
- }, 1000)
- }
- }
-
- // 삭제 성공 시 처리
- const handleDeleteSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
- setShowDeleteDialog(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
- }
-
- // Short List 확정 성공 시 처리
- const handleShortListSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
- setShowShortListDialog(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- // 최종 RFQ 페이지로 이동
- if (rfqId) {
- toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.")
- setTimeout(() => {
- router.push(`/evcp/b-rfq/${rfqId}`)
- }, 1500)
- }
- }
-
- // 선택된 항목 중 첫 번째를 기본값으로 사용
- const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined
-
- const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT")
- const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length
-
- // S/L 확정 가능한 벤더 수
- const validForShortList = allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- ).length
-
- return (
- <>
- <div className="flex items-center gap-2">
- {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
- {selectedCount > 0 && (
- <>
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkEmail}
- className="h-8"
- disabled={isEmailSending}
- >
- {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
- RFQ 발송 ({selectedCount})
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkDelete}
- className="h-8 text-red-600 hover:text-red-700"
- disabled={!canDelete || selectedCount === 0}
- title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({draftCount}/{selectedCount})
- </Button>
- </>
- )}
-
- {/* S/L 확정 버튼 */}
- {rfqId && (
- <Button
- variant="default"
- size="sm"
- onClick={handleSlConfirm}
- className="h-8"
- disabled={validForShortList === 0}
- title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`}
- >
- <CheckCircle2 className="mr-2 h-4 w-4" />
- S/L 확정 ({validForShortList})
- </Button>
- )}
-
- {/* 초기 RFQ 추가 버튼 */}
- {rfqId && (
- <AddInitialRfqDialog
- rfqId={rfqId}
- onSuccess={handleAddSuccess}
- defaultValues={defaultValues}
- />
- )}
- </div>
-
- {/* 삭제 다이얼로그 */}
- <DeleteInitialRfqDialog
- open={showDeleteDialog}
- onOpenChange={setShowDeleteDialog}
- initialRfqs={selectedDetails}
- showTrigger={false}
- onSuccess={handleDeleteSuccess}
- />
-
- {/* Short List 확정 다이얼로그 */}
- {rfqId && (
- <ShortListConfirmDialog
- open={showShortListDialog}
- onOpenChange={setShowShortListDialog}
- rfqId={rfqId}
- vendors={allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- )}
- onSuccess={handleShortListSuccess}
- />
- )}
- </>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx
deleted file mode 100644
index 92c62dc0..00000000
--- a/lib/b-rfq/initial/short-list-confirm-dialog.tsx
+++ /dev/null
@@ -1,269 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-import { Loader2, Building, CheckCircle2, XCircle } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { shortListConfirm } from "../service"
-import { InitialRfqDetailView } from "@/db/schema"
-
-const shortListSchema = z.object({
- selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."),
-})
-
-type ShortListFormData = z.infer<typeof shortListSchema>
-
-interface ShortListConfirmDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- rfqId: number
- vendors: InitialRfqDetailView[]
- onSuccess?: () => void
-}
-
-export function ShortListConfirmDialog({
- open,
- onOpenChange,
- rfqId,
- vendors,
- onSuccess
-}: ShortListConfirmDialogProps) {
- const [isLoading, setIsLoading] = React.useState(false)
-
- const form = useForm<ShortListFormData>({
- resolver: zodResolver(shortListSchema),
- defaultValues: {
- selectedVendorIds: vendors
- .filter(vendor => vendor.shortList === true)
- .map(vendor => vendor.vendorId)
- .filter(Boolean) as number[]
- },
- })
-
- const watchedSelectedIds = form.watch("selectedVendorIds")
-
- // 선택된/탈락된 벤더 계산
- const selectedVendors = vendors.filter(vendor =>
- vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
- )
- const rejectedVendors = vendors.filter(vendor =>
- vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId)
- )
-
- async function onSubmit(data: ShortListFormData) {
- if (!rfqId) return
-
- setIsLoading(true)
-
- try {
- const result = await shortListConfirm({
- rfqId,
- selectedVendorIds: data.selectedVendorIds,
- rejectedVendorIds: vendors
- .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId))
- .map(v => v.vendorId!)
- })
-
- if (result.success) {
- toast.success(result.message)
- onOpenChange(false)
- form.reset()
- onSuccess?.()
- } else {
- toast.error(result.message || "Short List 확정에 실패했습니다.")
- }
- } catch (error) {
- console.error("Short List confirm error:", error)
- toast.error("Short List 확정 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleVendorToggle = (vendorId: number, checked: boolean) => {
- const currentSelected = form.getValues("selectedVendorIds")
-
- if (checked) {
- form.setValue("selectedVendorIds", [...currentSelected, vendorId])
- } else {
- form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId))
- }
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <CheckCircle2 className="h-5 w-5 text-green-600" />
- Short List 확정
- </DialogTitle>
- <DialogDescription>
- 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- <FormField
- control={form.control}
- name="selectedVendorIds"
- render={() => (
- <FormItem>
- <FormLabel className="text-base font-semibold">
- 벤더 선택 ({vendors.length}개 업체)
- </FormLabel>
- <FormControl>
- <ScrollArea className="h-[400px] border rounded-md p-4">
- <div className="space-y-4">
- {vendors.map((vendor) => {
- const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
-
- return (
- <div
- key={vendor.vendorId}
- className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${
- isSelected
- ? 'border-green-200 bg-green-50'
- : 'border-red-100 bg-red-50'
- }`}
- >
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked)
- }
- className="mt-1"
- />
- <div className="flex-1 space-y-2">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium">{vendor.vendorName}</span>
- {isSelected ? (
- <Badge variant="secondary" className="bg-green-100 text-green-800">
- 선택됨
- </Badge>
- ) : (
- <Badge variant="secondary" className="bg-red-100 text-red-800">
- 탈락
- </Badge>
- )}
- </div>
- <div className="text-sm text-muted-foreground">
- <span className="font-mono">{vendor.vendorCode}</span>
- {vendor.vendorCountry && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span>
- </>
- )}
- {vendor.vendorCategory && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorCategory}</span>
- </>
- )}
- {vendor.vendorBusinessSize && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorBusinessSize}</span>
- </>
- )}
- </div>
- <div className="text-xs text-muted-foreground">
- RFQ 상태: <Badge variant="outline" className="text-xs">
- {vendor.initialRfqStatus}
- </Badge>
- </div>
- </div>
- </div>
- )
- })}
- </div>
- </ScrollArea>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요약 정보 */}
- <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
- <div className="space-y-2">
- <div className="flex items-center gap-2 text-green-700">
- <CheckCircle2 className="h-4 w-4" />
- <span className="font-medium">선택된 벤더</span>
- </div>
- <div className="text-2xl font-bold text-green-700">
- {selectedVendors.length}개 업체
- </div>
- {selectedVendors.length > 0 && (
- <div className="text-sm text-muted-foreground">
- {selectedVendors.map(v => v.vendorName).join(", ")}
- </div>
- )}
- </div>
- <div className="space-y-2">
- <div className="flex items-center gap-2 text-red-700">
- <XCircle className="h-4 w-4" />
- <span className="font-medium">탈락 벤더</span>
- </div>
- <div className="text-2xl font-bold text-red-700">
- {rejectedVendors.length}개 업체
- </div>
- {rejectedVendors.length > 0 && (
- <div className="text-sm text-muted-foreground">
- Letter of Regret 발송 예정
- </div>
- )}
- </div>
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isLoading || selectedVendors.length === 0}
- >
- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Short List 확정
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
deleted file mode 100644
index a19b5172..00000000
--- a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
+++ /dev/null
@@ -1,496 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Calendar } from "@/components/ui/calendar"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- } from "@/components/ui/command"
-import { Input } from "@/components/ui/input"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations"
-import { getIncotermsForSelection, modifyInitialRfq } from "../service"
-import { InitialRfqDetailView } from "@/db/schema"
-
-interface UpdateInitialRfqSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- initialRfq: InitialRfqDetailView | null
-}
-
-interface Incoterm {
- id: number
- code: string
- description: string
-}
-
-export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
- const [incotermsLoading, setIncotermsLoading] = React.useState(false)
- const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
-
- const loadIncoterms = React.useCallback(async () => {
- setIncotermsLoading(true)
- try {
- const incotermsList = await getIncotermsForSelection()
- setIncoterms(incotermsList)
- } catch (error) {
- console.error("Failed to load incoterms:", error)
- toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
- } finally {
- setIncotermsLoading(false)
- }
- }, [])
-
- React.useEffect(() => {
- if (incoterms.length === 0) {
- loadIncoterms()
- }
- }, [incoterms.length, loadIncoterms])
-
- const form = useForm<UpdateInitialRfqSchema>({
- resolver: zodResolver(updateInitialRfqSchema),
- defaultValues: {
- initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT",
- dueDate: initialRfq?.dueDate ?? new Date(),
- validDate: initialRfq?.validDate ?? undefined,
- incotermsCode: initialRfq?.incotermsCode ?? "",
- classification: initialRfq?.classification ?? "",
- sparepart: initialRfq?.sparepart ?? "",
- rfqRevision: initialRfq?.rfqRevision ?? 0,
- shortList: initialRfq?.shortList ?? false,
- returnYn: initialRfq?.returnYn ?? false,
- cpRequestYn: initialRfq?.cpRequestYn ?? false,
- prjectGtcYn: initialRfq?.prjectGtcYn ?? false,
- },
- })
-
- // initialRfq가 변경될 때 폼 값을 업데이트
- React.useEffect(() => {
- if (initialRfq) {
- form.reset({
- initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT",
- dueDate: initialRfq.dueDate,
- validDate: initialRfq.validDate,
- incotermsCode: initialRfq.incotermsCode ?? "",
- classification: initialRfq.classification ?? "",
- sparepart: initialRfq.sparepart ?? "",
- shortList: initialRfq.shortList ?? false,
- returnYn: initialRfq.returnYn ?? false,
- rfqRevision: initialRfq.rfqRevision ?? 0,
- cpRequestYn: initialRfq.cpRequestYn ?? false,
- prjectGtcYn: initialRfq.prjectGtcYn ?? false,
- })
- }
- }, [initialRfq, form])
-
- function onSubmit(input: UpdateInitialRfqSchema) {
- startUpdateTransition(async () => {
- if (!initialRfq || !initialRfq.initialRfqId) {
- toast.error("유효하지 않은 RFQ입니다.")
- return
- }
-
- const { error } = await modifyInitialRfq({
- id: initialRfq.initialRfqId,
- ...input,
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- form.reset()
- props.onOpenChange?.(false)
- toast.success("초기 RFQ가 수정되었습니다")
- })
- }
-
- const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col h-full sm:max-w-md">
- {/* 고정 헤더 */}
- <SheetHeader className="flex-shrink-0 text-left pb-6">
- <SheetTitle>초기 RFQ 수정</SheetTitle>
- <SheetDescription>
- 초기 RFQ 정보를 수정하고 변경사항을 저장하세요
- </SheetDescription>
- </SheetHeader>
-
- {/* 스크롤 가능한 폼 영역 */}
- <div className="flex-1 overflow-y-auto">
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4 pr-2"
- >
- {/* RFQ 리비전 */}
- <FormField
- control={form.control}
- name="rfqRevision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 리비전</FormLabel>
- <FormControl>
- <Input
- type="number"
- min="0"
- placeholder="0"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 마감일 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일 *</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 유효일 */}
- <FormField
- control={form.control}
- name="validDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>유효일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Incoterms 코드 */}
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>Incoterms</FormLabel>
- <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={incotermsSearchOpen}
- className="justify-between"
- disabled={incotermsLoading}
- >
- {selectedIncoterm ? (
- <div className="flex items-center gap-2">
- <span className="truncate">
- {selectedIncoterm.code} - {selectedIncoterm.description}
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="코드 또는 내용으로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {incoterms.map((incoterm) => (
- <CommandItem
- key={incoterm.id}
- value={`${incoterm.code} ${incoterm.description}`}
- onSelect={() => {
- field.onChange(incoterm.code)
- setIncotermsSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {incoterm.code} - {incoterm.description}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- incoterm.code === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- {/* 체크박스 옵션들 */}
- <div className="space-y-3">
- <FormField
- control={form.control}
- name="shortList"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>Short List</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="returnYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>회신 여부</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- {/* 선급 */}
- <FormField
- control={form.control}
- name="classification"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선급</FormLabel>
- <FormControl>
- <Input
- placeholder="선급"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 예비부품 */}
- <FormField
- control={form.control}
- name="sparepart"
- render={({ field }) => (
- <FormItem>
- <FormLabel>예비부품</FormLabel>
- <FormControl>
- <Input
- placeholder="O1, O2"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
-
-
-
- <FormField
- control={form.control}
- name="cpRequestYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>CP 요청</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="prjectGtcYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>프로젝트 GTC</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </div>
-
- {/* 하단 여백 */}
- <div className="h-4" />
- </form>
- </Form>
- </div>
-
- {/* 고정 푸터 */}
- <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button
- onClick={form.handleSubmit(onSubmit)}
- disabled={isUpdatePending}
- >
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 저장
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/repository.ts b/lib/b-rfq/repository.ts
deleted file mode 100644
index e69de29b..00000000
--- a/lib/b-rfq/repository.ts
+++ /dev/null
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
deleted file mode 100644
index 896a082d..00000000
--- a/lib/b-rfq/service.ts
+++ /dev/null
@@ -1,2976 +0,0 @@
-'use server'
-
-import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from "next/cache"
-import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
-import { filterColumns } from "@/lib/filter-columns"
-import db from "@/db/db"
-import {
- vendorResponseDetailView,
- attachmentRevisionHistoryView,
- rfqProgressSummaryView,
- vendorResponseAttachmentsEnhanced, Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
- vendorResponseAttachmentsB,
- finalRfq,
- finalRfqDetailView
-} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
-import { rfqDashboardView } from "@/db/schema" // 뷰 import
-import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, ShortListConfirmInput, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema, shortListConfirmSchema, GetFinalRfqDetailSchema } from "./validations"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { unlink } from "fs/promises"
-import { getErrorMessage } from "../handle-error"
-import { AddInitialRfqFormData } from "./initial/add-initial-rfq-dialog"
-import { sendEmail } from "../mail/sendEmail"
-import { RfqType } from "../rfqs/validations"
-
-const tag = {
- initialRfqDetail: "initial-rfq",
- rfqDashboard: 'rfq-dashboard',
- rfq: (id: number) => `rfq-${id}`,
- rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`,
- attachmentRevisions: (attId: number) => `attachment-revisions-${attId}`,
- vendorResponses: (
- attId: number,
- type: 'INITIAL' | 'FINAL' = 'INITIAL',
- ) => `vendor-responses-${attId}-${type}`,
-} as const;
-
-export async function getRFQDashboard(input: GetRFQDashboardSchema) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const rfqFilterMapping = createRFQFilterMapping();
- const joinedTables = getRFQJoinedTables();
-
- console.log(input, "견적 인풋")
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
-
- const descriptionCondition = ilike(rfqDashboardView.description, s);
- if (descriptionCondition) validSearchConditions.push(descriptionCondition);
-
- const projectNameCondition = ilike(rfqDashboardView.projectName, s);
- if (projectNameCondition) validSearchConditions.push(projectNameCondition);
-
- const projectCodeCondition = ilike(rfqDashboardView.projectCode, s);
- if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
-
- const picNameCondition = ilike(rfqDashboardView.picName, s);
- if (picNameCondition) validSearchConditions.push(picNameCondition);
-
- const packageNoCondition = ilike(rfqDashboardView.packageNo, s);
- if (packageNoCondition) validSearchConditions.push(packageNoCondition);
-
- const packageNameCondition = ilike(rfqDashboardView.packageName, s);
- if (packageNameCondition) validSearchConditions.push(packageNameCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
-
-
- // 6) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
-
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 7) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(rfqDashboardView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(total)
-
- // 8) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
- return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(rfqDashboardView.createdAt));
- }
-
- const rfqData = await db
- .select()
- .from(rfqDashboardView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: rfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getRFQDashboard:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-
-}
-
-// 헬퍼 함수들
-function createRFQFilterMapping() {
- return {
- // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑
- rfqCode: rfqDashboardView.rfqCode,
- description: rfqDashboardView.description,
- status: rfqDashboardView.status,
- projectName: rfqDashboardView.projectName,
- projectCode: rfqDashboardView.projectCode,
- picName: rfqDashboardView.picName,
- packageNo: rfqDashboardView.packageNo,
- packageName: rfqDashboardView.packageName,
- dueDate: rfqDashboardView.dueDate,
- overallProgress: rfqDashboardView.overallProgress,
- createdAt: rfqDashboardView.createdAt,
- };
-}
-
-function getRFQJoinedTables() {
- return {
- // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음)
- projects,
- users,
- };
-}
-
-// ================================================================
-// 3. RFQ Dashboard 타입 정의
-// ================================================================
-
-async function generateNextSerial(picCode: string): Promise<string> {
- try {
- // 해당 picCode로 시작하는 RFQ 개수 조회
- const existingCount = await db
- .select({ count: count() })
- .from(bRfqs)
- .where(eq(bRfqs.picCode, picCode))
-
- const nextSerial = (existingCount[0]?.count || 0) + 1
- return nextSerial.toString().padStart(5, '0') // 5자리로 패딩
- } catch (error) {
- console.error("시리얼 번호 생성 오류:", error)
- return "00001" // 기본값
- }
-}
-
-export async function createRfqAction(input: CreateRfqInput) {
- try {
- // 입력 데이터 검증
- const validatedData = createRfqServerSchema.parse(input)
-
- // RFQ 코드 자동 생성: N + picCode + 시리얼5자리
- const serialNumber = await generateNextSerial(validatedData.picCode)
- const rfqCode = `N${validatedData.picCode}${serialNumber}`
-
- // 데이터베이스에 삽입
- const result = await db.insert(bRfqs).values({
- rfqCode,
- projectId: validatedData.projectId,
- dueDate: validatedData.dueDate,
- status: "DRAFT",
- picCode: validatedData.picCode,
- picName: validatedData.picName || null,
- EngPicName: validatedData.engPicName || null,
- packageNo: validatedData.packageNo || null,
- packageName: validatedData.packageName || null,
- remark: validatedData.remark || null,
- projectCompany: validatedData.projectCompany || null,
- projectFlag: validatedData.projectFlag || null,
- projectSite: validatedData.projectSite || null,
- createdBy: validatedData.createdBy,
- updatedBy: validatedData.updatedBy,
- }).returning({
- id: bRfqs.id,
- rfqCode: bRfqs.rfqCode,
- })
-
-
-
- return {
- success: true,
- data: result[0],
- message: "RFQ가 성공적으로 생성되었습니다",
- }
-
- } catch (error) {
- console.error("RFQ 생성 오류:", error)
-
-
- return {
- success: false,
- error: "RFQ 생성에 실패했습니다",
- }
- }
-}
-
-// RFQ 코드 중복 확인 액션
-export async function checkRfqCodeExists(rfqCode: string) {
- try {
- const existing = await db.select({ id: bRfqs.id })
- .from(bRfqs)
- .where(eq(bRfqs.rfqCode, rfqCode))
- .limit(1)
-
- return existing.length > 0
- } catch (error) {
- console.error("RFQ 코드 확인 오류:", error)
- return false
- }
-}
-
-// picCode별 다음 예상 RFQ 코드 미리보기
-export async function previewNextRfqCode(picCode: string) {
- try {
- const serialNumber = await generateNextSerial(picCode)
- return `N${picCode}${serialNumber}`
- } catch (error) {
- console.error("RFQ 코드 미리보기 오류:", error)
- return `N${picCode}00001`
- }
-}
-
-const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => {
- // 1) RFQ 단건 조회
- const rfqsRes = await db
- .select()
- .from(rfqDashboardView)
- .where(eq(rfqDashboardView.rfqId, id))
- .limit(1);
-
- if (rfqsRes.length === 0) return null;
- const rfqRow = rfqsRes[0];
-
- // 3) RfqWithItems 형태로 반환
- const result: RfqDashboardView = {
- ...rfqRow,
-
- };
-
- return result;
-};
-
-
-export const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => {
- try {
-
- const rfq = await getBRfqById(id);
-
- return rfq;
- } catch (error) {
- throw new Error('Failed to fetch user');
- }
-};
-
-
-export async function getRfqAttachments(
- input: GetRfqAttachmentsSchema,
- rfqId: number
-) {
- try {
- const offset = (input.page - 1) * input.perPage
-
- // Advanced Filter 처리 (메인 테이블 기준)
- const advancedWhere = filterColumns({
- table: bRfqsAttachments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
-
- // 전역 검색 (첨부파일 + 리비전 파일명 검색)
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- ilike(bRfqsAttachments.serialNo, s),
- ilike(bRfqsAttachments.description, s),
- ilike(bRfqsAttachments.currentRevision, s),
- ilike(bRfqAttachmentRevisions.fileName, s),
- ilike(bRfqAttachmentRevisions.originalFileName, s)
- )
- }
-
- // 기본 필터
- let basicWhere
- if (input.attachmentType.length > 0 || input.fileType.length > 0) {
- basicWhere = and(
- input.attachmentType.length > 0
- ? inArray(bRfqsAttachments.attachmentType, input.attachmentType)
- : undefined,
- input.fileType.length > 0
- ? inArray(bRfqAttachmentRevisions.fileType, input.fileType)
- : undefined
- )
- }
-
- // 최종 WHERE 절
- const finalWhere = and(
- eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건
- advancedWhere,
- globalWhere,
- basicWhere
- )
-
- // 정렬 (메인 테이블 기준)
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments])
- )
- : [desc(bRfqsAttachments.createdAt)]
-
- // 트랜잭션으로 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
- const data = await tx
- .select({
- // 첨부파일 메인 정보
- id: bRfqsAttachments.id,
- attachmentType: bRfqsAttachments.attachmentType,
- serialNo: bRfqsAttachments.serialNo,
- rfqId: bRfqsAttachments.rfqId,
- currentRevision: bRfqsAttachments.currentRevision,
- latestRevisionId: bRfqsAttachments.latestRevisionId,
- description: bRfqsAttachments.description,
- createdBy: bRfqsAttachments.createdBy,
- createdAt: bRfqsAttachments.createdAt,
- updatedAt: bRfqsAttachments.updatedAt,
-
- // 최신 리비전 파일 정보
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
-
- // 생성자 정보
- createdByName: users.name,
- })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- and(
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id),
- eq(bRfqAttachmentRevisions.isLatest, true)
- )
- )
- .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id))
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(input.perPage)
- .offset(offset)
-
- // 전체 개수 조회
- const totalResult = await tx
- .select({ count: count() })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id)
- )
- .where(finalWhere)
-
- const total = totalResult[0]?.count ?? 0
-
- return { data, total }
- })
-
- const pageCount = Math.ceil(total / input.perPage)
-
- // 각 첨부파일별 벤더 응답 통계 조회
- const attachmentIds = data.map(item => item.id)
- let responseStatsMap: Record<number, any> = {}
-
- if (attachmentIds.length > 0) {
- responseStatsMap = await getAttachmentResponseStats(attachmentIds)
- }
-
- // 통계 데이터 병합
- const dataWithStats = data.map(attachment => ({
- ...attachment,
- responseStats: responseStatsMap[attachment.id] || {
- totalVendors: 0,
- respondedCount: 0,
- pendingCount: 0,
- waivedCount: 0,
- responseRate: 0
- }
- }))
-
- return { data: dataWithStats, pageCount }
- } catch (err) {
- console.error("getRfqAttachments error:", err)
- return { data: [], pageCount: 0 }
- }
-
-}
-
-// 첨부파일별 벤더 응답 통계 조회
-async function getAttachmentResponseStats(attachmentIds: number[]) {
- try {
- const stats = await db
- .select({
- attachmentId: vendorAttachmentResponses.attachmentId,
- totalVendors: count(),
- respondedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' then 1 end)`,
- pendingCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' then 1 end)`,
- waivedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'WAIVED' then 1 end)`,
- })
- .from(vendorAttachmentResponses)
- .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds))
- .groupBy(vendorAttachmentResponses.attachmentId)
-
- // 응답률 계산해서 객체로 변환
- const statsMap: Record<number, any> = {}
- stats.forEach(stat => {
- const activeVendors = stat.totalVendors - stat.waivedCount
- const responseRate = activeVendors > 0
- ? Math.round((stat.respondedCount / activeVendors) * 100)
- : 0
-
- statsMap[stat.attachmentId] = {
- totalVendors: stat.totalVendors,
- respondedCount: stat.respondedCount,
- pendingCount: stat.pendingCount,
- waivedCount: stat.waivedCount,
- responseRate
- }
- })
-
- return statsMap
- } catch (error) {
- console.error("getAttachmentResponseStats error:", error)
- return {}
- }
-}
-
-// 특정 첨부파일에 대한 벤더 응답 현황 상세 조회
-export async function getVendorResponsesForAttachment(
- attachmentId: number,
- rfqType: 'INITIAL' | 'FINAL' = 'INITIAL'
-) {
- try {
- // 1. 기본 벤더 응답 정보 가져오기 (첨부파일 정보와 조인)
- const responses = await db
- .select({
- id: vendorAttachmentResponses.id,
- attachmentId: vendorAttachmentResponses.attachmentId,
- vendorId: vendorAttachmentResponses.vendorId,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName,
- vendorCountry: vendors.country,
- rfqType: vendorAttachmentResponses.rfqType,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- responseStatus: vendorAttachmentResponses.responseStatus,
-
- // 첨부파일의 현재 리비전 (가장 중요!)
- currentRevision: bRfqsAttachments.currentRevision,
-
- // 벤더가 응답한 리비전
- respondedRevision: vendorAttachmentResponses.respondedRevision,
-
- responseComment: vendorAttachmentResponses.responseComment,
- vendorComment: vendorAttachmentResponses.vendorComment,
-
- // 새로 추가된 필드들
- revisionRequestComment: vendorAttachmentResponses.revisionRequestComment,
- revisionRequestedAt: vendorAttachmentResponses.revisionRequestedAt,
- requestedAt: vendorAttachmentResponses.requestedAt,
- respondedAt: vendorAttachmentResponses.respondedAt,
- updatedAt: vendorAttachmentResponses.updatedAt,
- })
- .from(vendorAttachmentResponses)
- .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id))
- .leftJoin(bRfqsAttachments, eq(vendorAttachmentResponses.attachmentId, bRfqsAttachments.id))
- .where(
- and(
- eq(vendorAttachmentResponses.attachmentId, attachmentId),
- eq(vendorAttachmentResponses.rfqType, rfqType)
- )
- )
- .orderBy(vendors.vendorName);
-
- // 2. 각 응답에 대한 파일 정보 가져오기
- const responseIds = responses.map(r => r.id);
-
- let responseFiles: any[] = [];
- if (responseIds.length > 0) {
- responseFiles = await db
- .select({
- id: vendorResponseAttachmentsB.id,
- vendorResponseId: vendorResponseAttachmentsB.vendorResponseId,
- fileName: vendorResponseAttachmentsB.fileName,
- originalFileName: vendorResponseAttachmentsB.originalFileName,
- filePath: vendorResponseAttachmentsB.filePath,
- fileSize: vendorResponseAttachmentsB.fileSize,
- fileType: vendorResponseAttachmentsB.fileType,
- description: vendorResponseAttachmentsB.description,
- uploadedAt: vendorResponseAttachmentsB.uploadedAt,
- })
- .from(vendorResponseAttachmentsB)
- .where(inArray(vendorResponseAttachmentsB.vendorResponseId, responseIds))
- .orderBy(desc(vendorResponseAttachmentsB.uploadedAt));
- }
-
- // 3. 응답에 파일 정보 병합 및 리비전 상태 체크
- const enhancedResponses = responses.map(response => {
- const files = responseFiles.filter(file => file.vendorResponseId === response.id);
- const latestFile = files
- .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())[0] || null;
-
- // 벤더가 최신 리비전에 응답했는지 체크
- const isUpToDate = response.respondedRevision === response.currentRevision;
-
- return {
- ...response,
- files,
- totalFiles: files.length,
- latestFile,
- isUpToDate, // 최신 리비전 응답 여부
- };
- });
-
- return enhancedResponses;
- } catch (err) {
- console.error("getVendorResponsesForAttachment error:", err);
- return [];
- }
-}
-
-export async function confirmDocuments(rfqId: number) {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- // TODO: RFQ 상태를 "Doc. Confirmed"로 업데이트
- await db
- .update(bRfqs)
- .set({
- status: "Doc. Confirmed",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqId))
-
-
- return {
- success: true,
- message: "문서가 확정되었습니다.",
- }
-
- } catch (error) {
- console.error("confirmDocuments error:", error)
- return {
- success: false,
- message: error instanceof Error ? error.message : "문서 확정 중 오류가 발생했습니다.",
- }
- }
-}
-
-// TBE 요청 서버 액션
-export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- // attachmentIds가 제공된 경우 해당 첨부파일들만 처리
- let targetAttachments = []
- if (attachmentIds && attachmentIds.length > 0) {
- // 선택된 첨부파일들 조회
- targetAttachments = await db
- .select({
- id: bRfqsAttachments.id,
- serialNo: bRfqsAttachments.serialNo,
- attachmentType: bRfqsAttachments.attachmentType,
- currentRevision: bRfqsAttachments.currentRevision,
- })
- .from(bRfqsAttachments)
- .where(
- and(
- eq(bRfqsAttachments.rfqId, rfqId),
- inArray(bRfqsAttachments.id, attachmentIds)
- )
- )
-
- if (targetAttachments.length === 0) {
- throw new Error("선택된 첨부파일을 찾을 수 없습니다.")
- }
- } else {
- // 전체 RFQ의 모든 첨부파일 처리
- targetAttachments = await db
- .select({
- id: bRfqsAttachments.id,
- serialNo: bRfqsAttachments.serialNo,
- attachmentType: bRfqsAttachments.attachmentType,
- currentRevision: bRfqsAttachments.currentRevision,
- })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, rfqId))
- }
-
- if (targetAttachments.length === 0) {
- throw new Error("TBE 요청할 첨부파일이 없습니다.")
- }
-
- // TODO: TBE 요청 로직 구현
- // 1. RFQ 상태를 "TBE started"로 업데이트 (선택적)
- // 2. 선택된 첨부파일들에 대해 벤더들에게 TBE 요청 이메일 발송
- // 3. vendorAttachmentResponses 테이블에 TBE 요청 레코드 생성
- // 4. TBE 관련 메타데이터 업데이트
-
-
-
- // 예시: 선택된 첨부파일들에 대한 벤더 응답 레코드 생성
- await db.transaction(async (tx) => {
-
- const [updatedRfq] = await tx
- .update(bRfqs)
- .set({
- status: "TBE started",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqId))
- .returning()
-
- // 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트
- for (const attachment of targetAttachments) {
- // TODO: 해당 첨부파일과 연관된 벤더들에게 TBE 요청 처리
- console.log(`TBE 요청 처리: ${attachment.serialNo} (${attachment.currentRevision})`)
- }
- })
-
-
- const attachmentCount = targetAttachments.length
- const attachmentList = targetAttachments
- .map(a => `${a.serialNo} (${a.currentRevision})`)
- .join(', ')
-
- return {
- success: true,
- message: `${attachmentCount}개 문서에 대한 TBE 요청이 전송되었습니다.\n대상: ${attachmentList}`,
- targetAttachments,
- }
-
- } catch (error) {
- console.error("requestTbe error:", error)
- return {
- success: false,
- message: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다.",
- }
- }
-}
-
-// 다음 시리얼 번호 생성
-async function getNextSerialNo(rfqId: number): Promise<string> {
- try {
- // 해당 RFQ의 기존 첨부파일 개수 조회
- const [result] = await db
- .select({ count: count() })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, rfqId))
-
- const nextNumber = (result?.count || 0) + 1
-
- // 001, 002, 003... 형태로 포맷팅
- return nextNumber.toString().padStart(3, '0')
-
- } catch (error) {
- console.error("getNextSerialNo error:", error)
- // 에러 발생 시 타임스탬프 기반으로 fallback
- return Date.now().toString().slice(-3)
- }
-}
-
-export async function addRfqAttachmentRecord(record: AttachmentRecord) {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- const validatedRecord = attachmentRecordSchema.parse(record)
- const userId = Number(session.user.id)
-
- const result = await db.transaction(async (tx) => {
- // 1. 시리얼 번호 생성
- const [countResult] = await tx
- .select({ count: count() })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, validatedRecord.rfqId))
-
- const serialNo = (countResult.count + 1).toString().padStart(3, '0')
-
- // 2. 메인 첨부파일 레코드 생성
- const [attachment] = await tx
- .insert(bRfqsAttachments)
- .values({
- rfqId: validatedRecord.rfqId,
- attachmentType: validatedRecord.attachmentType,
- serialNo: serialNo,
- currentRevision: "Rev.0",
- description: validatedRecord.description,
- createdBy: userId,
- })
- .returning()
-
- // 3. 초기 리비전 (Rev.0) 생성
- const [revision] = await tx
- .insert(bRfqAttachmentRevisions)
- .values({
- attachmentId: attachment.id,
- revisionNo: "Rev.0",
- fileName: validatedRecord.fileName,
- originalFileName: validatedRecord.originalFileName,
- filePath: validatedRecord.filePath,
- fileSize: validatedRecord.fileSize,
- fileType: validatedRecord.fileType,
- revisionComment: validatedRecord.revisionComment,
- isLatest: true,
- createdBy: userId,
- })
- .returning()
-
- // 4. 메인 테이블의 latest_revision_id 업데이트
- await tx
- .update(bRfqsAttachments)
- .set({
- latestRevisionId: revision.id,
- updatedAt: new Date(),
- })
- .where(eq(bRfqsAttachments.id, attachment.id))
-
- return { attachment, revision }
- })
-
- return {
- success: true,
- message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`,
- attachment: result.attachment,
- revision: result.revision,
- }
-
- } catch (error) {
- console.error("addRfqAttachmentRecord error:", error)
- return {
- success: false,
- message: error instanceof Error ? error.message : "첨부파일 등록 중 오류가 발생했습니다.",
- }
- }
-}
-
-// 리비전 추가 (기존 첨부파일에 새 버전 추가)
-export async function addRevisionToAttachment(
- attachmentId: number,
- revisionData: {
- fileName: string;
- originalFileName: string;
- filePath: string;
- fileSize: number;
- fileType: string;
- revisionComment?: string;
- },
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) throw new Error('인증이 필요합니다.');
-
- const userId = Number(session.user.id);
-
- // ────────────────────────────────────────────────────────────────────────────
- // 0. 첨부파일의 rfqId 사전 조회 (태그 무효화를 위해 필요)
- // ────────────────────────────────────────────────────────────────────────────
- const [attInfo] = await db
- .select({ rfqId: bRfqsAttachments.rfqId })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.id, attachmentId))
- .limit(1);
-
- if (!attInfo) throw new Error('첨부파일을 찾을 수 없습니다.');
- const rfqId = attInfo.rfqId;
-
- // ────────────────────────────────────────────────────────────────────────────
- // 1‑5. 리비전 트랜잭션
- // ────────────────────────────────────────────────────────────────────────────
- const newRevision = await db.transaction(async (tx) => {
- // 1. 현재 최신 리비전 조회
- const [latestRevision] = await tx
- .select({ revisionNo: bRfqAttachmentRevisions.revisionNo })
- .from(bRfqAttachmentRevisions)
- .where(
- and(
- eq(bRfqAttachmentRevisions.attachmentId, attachmentId),
- eq(bRfqAttachmentRevisions.isLatest, true),
- ),
- );
-
- if (!latestRevision) throw new Error('기존 첨부파일을 찾을 수 없습니다.');
-
- // 2. 새 리비전 번호 생성
- const currentNum = parseInt(latestRevision.revisionNo.replace('Rev.', ''));
- const newRevisionNo = `Rev.${currentNum + 1}`;
-
- // 3. 기존 리비전 isLatest → false
- await tx
- .update(bRfqAttachmentRevisions)
- .set({ isLatest: false })
- .where(
- and(
- eq(bRfqAttachmentRevisions.attachmentId, attachmentId),
- eq(bRfqAttachmentRevisions.isLatest, true),
- ),
- );
-
- // 4. 새 리비전 INSERT
- const [inserted] = await tx
- .insert(bRfqAttachmentRevisions)
- .values({
- attachmentId,
- revisionNo: newRevisionNo,
- fileName: revisionData.fileName,
- originalFileName: revisionData.originalFileName,
- filePath: revisionData.filePath,
- fileSize: revisionData.fileSize,
- fileType: revisionData.fileType,
- revisionComment: revisionData.revisionComment ?? `${newRevisionNo} 업데이트`,
- isLatest: true,
- createdBy: userId,
- })
- .returning();
-
- // 5. 메인 첨부파일 row 업데이트
- await tx
- .update(bRfqsAttachments)
- .set({
- currentRevision: newRevisionNo,
- latestRevisionId: inserted.id,
- updatedAt: new Date(),
- })
- .where(eq(bRfqsAttachments.id, attachmentId));
-
- return inserted;
- });
-
-
-
- return {
- success: true,
- message: `새 리비전(${newRevision.revisionNo})이 성공적으로 추가되었습니다.`,
- revision: newRevision,
- };
- } catch (error) {
- console.error('addRevisionToAttachment error:', error);
- return {
- success: false,
- message: error instanceof Error ? error.message : '리비전 추가 중 오류가 발생했습니다.',
- };
- }
-}
-
-// 특정 첨부파일의 모든 리비전 조회
-export async function getAttachmentRevisions(attachmentId: number) {
-
- try {
- const revisions = await db
- .select({
- id: bRfqAttachmentRevisions.id,
- revisionNo: bRfqAttachmentRevisions.revisionNo,
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
- isLatest: bRfqAttachmentRevisions.isLatest,
- createdBy: bRfqAttachmentRevisions.createdBy,
- createdAt: bRfqAttachmentRevisions.createdAt,
- createdByName: users.name,
- })
- .from(bRfqAttachmentRevisions)
- .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id))
- .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId))
- .orderBy(desc(bRfqAttachmentRevisions.createdAt))
-
- return {
- success: true,
- revisions,
- }
- } catch (error) {
- console.error("getAttachmentRevisions error:", error)
- return {
- success: false,
- message: "리비전 조회 중 오류가 발생했습니다.",
- revisions: [],
- }
- }
-}
-
-
-// 첨부파일 삭제 (리비전 포함)
-export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- const validatedInput = deleteAttachmentsSchema.parse(input)
-
- const result = await db.transaction(async (tx) => {
- // 1. 삭제할 첨부파일들의 정보 조회 (파일 경로 포함)
- const attachmentsToDelete = await tx
- .select({
- id: bRfqsAttachments.id,
- rfqId: bRfqsAttachments.rfqId,
- serialNo: bRfqsAttachments.serialNo,
- })
- .from(bRfqsAttachments)
- .where(inArray(bRfqsAttachments.id, validatedInput.ids))
-
- if (attachmentsToDelete.length === 0) {
- throw new Error("삭제할 첨부파일을 찾을 수 없습니다.")
- }
-
- // 2. 관련된 모든 리비전 파일 경로 조회
- const revisionFilePaths = await tx
- .select({ filePath: bRfqAttachmentRevisions.filePath })
- .from(bRfqAttachmentRevisions)
- .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids))
-
- // 3. DB에서 리비전 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
- await tx
- .delete(bRfqAttachmentRevisions)
- .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids))
-
- // 4. DB에서 첨부파일 삭제
- await tx
- .delete(bRfqsAttachments)
- .where(inArray(bRfqsAttachments.id, validatedInput.ids))
-
- // 5. 실제 파일 삭제 (비동기로 처리)
- Promise.all(
- revisionFilePaths.map(async ({ filePath }) => {
- try {
- if (filePath) {
- const fullPath = `${process.cwd()}/public${filePath}`
- await unlink(fullPath)
- }
- } catch (fileError) {
- console.warn(`Failed to delete file: ${filePath}`, fileError)
- }
- })
- ).catch(error => {
- console.error("Some files failed to delete:", error)
- })
-
- return {
- deletedCount: attachmentsToDelete.length,
- rfqIds: [...new Set(attachmentsToDelete.map(a => a.rfqId))],
- attachments: attachmentsToDelete,
- }
- })
-
-
- return {
- success: true,
- message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`,
- deletedAttachments: result.attachments,
- }
-
- } catch (error) {
- console.error("deleteRfqAttachments error:", error)
-
- return {
- success: false,
- message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.",
- }
- }
-}
-
-
-
-//Initial RFQ
-
-export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
-
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
- }
-
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
-
- const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
-
- const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
-
- const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
-
- const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
-
- const classificationCondition = ilike(initialRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
-
- const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
-
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
-
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(initialRfqDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(totalResult);
- console.log(total);
-
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
- return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(initialRfqDetailView.createdAt));
- }
-
- const initialRfqData = await db
- .select()
- .from(initialRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: initialRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getInitialRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-}
-
-export async function getVendorsForSelection() {
- try {
- const vendorsData = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- taxId: vendors.taxId,
- country: vendors.country,
- status: vendors.status,
- })
- .from(vendors)
- // .where(
- // and(
- // ne(vendors.status, "BLACKLISTED"),
- // ne(vendors.status, "REJECTED")
- // )
- // )
- .orderBy(vendors.vendorName)
-
-
- return vendorsData.map(vendor => ({
- id: vendor.id,
- vendorName: vendor.vendorName || "",
- vendorCode: vendor.vendorCode || "",
- country: vendor.country || "",
- status: vendor.status,
- }))
- } catch (error) {
- console.log("Error fetching vendors:", error)
- throw new Error("Failed to fetch vendors")
- }
-}
-
-export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
- try {
- console.log('Incoming data:', data);
-
- const [newRecord] = await db
- .insert(initialRfq)
- .values({
- rfqId: data.rfqId,
- vendorId: data.vendorId,
- initialRfqStatus: data.initialRfqStatus,
- dueDate: data.dueDate,
- validDate: data.validDate,
- incotermsCode: data.incotermsCode,
- gtc: data.gtc,
- gtcValidDate: data.gtcValidDate,
- classification: data.classification,
- sparepart: data.sparepart,
- shortList: data.shortList,
- returnYn: data.returnYn,
- cpRequestYn: data.cpRequestYn,
- prjectGtcYn: data.prjectGtcYn,
- returnRevision: data.returnRevision,
- })
- .returning()
-
- return {
- success: true,
- message: "초기 RFQ가 성공적으로 추가되었습니다.",
- data: newRecord,
- }
- } catch (error) {
- console.error("Error adding initial RFQ:", error)
- return {
- success: false,
- message: "초기 RFQ 추가에 실패했습니다.",
- error,
- }
- }
-}
-
-export async function getIncotermsForSelection() {
- try {
- const incotermData = await db
- .select({
- code: incoterms.code,
- description: incoterms.description,
- })
- .from(incoterms)
- .orderBy(incoterms.code)
-
- return incotermData
-
- } catch (error) {
- console.error("Error fetching incoterms:", error)
- throw new Error("Failed to fetch incoterms")
- }
-}
-
-export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) {
- unstable_noStore()
- try {
- const { ids } = removeInitialRfqsSchema.parse(input)
-
- await db.transaction(async (tx) => {
- await tx.delete(initialRfq).where(inArray(initialRfq.id, ids))
- })
-
-
- return {
- data: null,
- error: null,
- }
- } catch (err) {
- return {
- data: null,
- error: getErrorMessage(err),
- }
- }
-}
-
-interface ModifyInitialRfqInput extends UpdateInitialRfqSchema {
- id: number
-}
-
-export async function modifyInitialRfq(input: ModifyInitialRfqInput) {
- unstable_noStore()
- try {
- const { id, ...updateData } = input
-
- // validation
- updateInitialRfqSchema.parse(updateData)
-
- await db.transaction(async (tx) => {
- const existingRfq = await tx
- .select()
- .from(initialRfq)
- .where(eq(initialRfq.id, id))
- .limit(1)
-
- if (existingRfq.length === 0) {
- throw new Error("초기 RFQ를 찾을 수 없습니다.")
- }
-
- await tx
- .update(initialRfq)
- .set({
- ...updateData,
- // Convert empty strings to null for optional fields
- incotermsCode: updateData.incotermsCode || null,
- gtc: updateData.gtc || null,
- gtcValidDate: updateData.gtcValidDate || null,
- classification: updateData.classification || null,
- sparepart: updateData.sparepart || null,
- validDate: updateData.validDate || null,
- updatedAt: new Date(),
- })
- .where(eq(initialRfq.id, id))
- })
-
-
- return {
- data: null,
- error: null,
- }
- } catch (err) {
- return {
- data: null,
- error: getErrorMessage(err),
- }
- }
-}
-
-
-
-
-// 이메일 발송용 데이터 타입
-interface EmailData {
- rfqCode: string
- projectName: string
- projectCompany: string
- projectFlag: string
- projectSite: string
- classification: string
- incotermsCode: string
- incotermsDescription: string
- dueDate: string
- validDate: string
- sparepart: string
- vendorName: string
- picName: string
- picEmail: string
- warrantyPeriod: string
- packageName: string
- rfqRevision: number
- emailType: string
-}
-
-export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
- unstable_noStore()
- try {
-
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- const { initialRfqIds, language } = bulkEmailSchema.parse(input)
-
- // 1. 선택된 초기 RFQ들의 상세 정보 조회
- const initialRfqDetails = await db
- .select({
- // initialRfqDetailView 필드들을 명시적으로 선택
- rfqId: initialRfqDetailView.rfqId,
- rfqCode: initialRfqDetailView.rfqCode,
- rfqStatus: initialRfqDetailView.rfqStatus,
- initialRfqId: initialRfqDetailView.initialRfqId,
- initialRfqStatus: initialRfqDetailView.initialRfqStatus,
- vendorId: initialRfqDetailView.vendorId,
- vendorCode: initialRfqDetailView.vendorCode,
- vendorName: initialRfqDetailView.vendorName,
- vendorCategory: initialRfqDetailView.vendorCategory,
- vendorCountry: initialRfqDetailView.vendorCountry,
- vendorBusinessSize: initialRfqDetailView.vendorBusinessSize,
- dueDate: initialRfqDetailView.dueDate,
- validDate: initialRfqDetailView.validDate,
- incotermsCode: initialRfqDetailView.incotermsCode,
- incotermsDescription: initialRfqDetailView.incotermsDescription,
- shortList: initialRfqDetailView.shortList,
- returnYn: initialRfqDetailView.returnYn,
- cpRequestYn: initialRfqDetailView.cpRequestYn,
- prjectGtcYn: initialRfqDetailView.prjectGtcYn,
- returnRevision: initialRfqDetailView.returnRevision,
- rfqRevision: initialRfqDetailView.rfqRevision,
- gtc: initialRfqDetailView.gtc,
- gtcValidDate: initialRfqDetailView.gtcValidDate,
- classification: initialRfqDetailView.classification,
- sparepart: initialRfqDetailView.sparepart,
- createdAt: initialRfqDetailView.createdAt,
- updatedAt: initialRfqDetailView.updatedAt,
- // bRfqs에서 추가로 필요한 필드들
- picName: bRfqs.picName,
- picCode: bRfqs.picCode,
- packageName: bRfqs.packageName,
- packageNo: bRfqs.packageNo,
- projectCompany: bRfqs.projectCompany,
- projectFlag: bRfqs.projectFlag,
- projectSite: bRfqs.projectSite,
- })
- .from(initialRfqDetailView)
- .leftJoin(bRfqs, eq(initialRfqDetailView.rfqId, bRfqs.id))
- .where(inArray(initialRfqDetailView.initialRfqId, initialRfqIds))
-
- if (initialRfqDetails.length === 0) {
- return {
- success: false,
- message: "선택된 초기 RFQ를 찾을 수 없습니다.",
- }
- }
-
- // 2. 각 RFQ에 대한 첨부파일 조회
- const rfqIds = [...new Set(initialRfqDetails.map(rfq => rfq.rfqId))].filter((id): id is number => id !== null)
- const attachments = await db
- .select()
- .from(bRfqsAttachments)
- .where(inArray(bRfqsAttachments.rfqId, rfqIds))
-
- // 3. 벤더 이메일 정보 조회 (모든 이메일 주소 포함)
- const vendorIds = [...new Set(initialRfqDetails.map(rfq => rfq.vendorId))].filter((id): id is number => id !== null)
- const vendorsWithAllEmails = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- representativeEmail: vendors.representativeEmail,
- // 연락처 이메일들을 JSON 배열로 집계
- contactEmails: sql<string[]>`
- COALESCE(
- (SELECT json_agg(contact_email)
- FROM vendor_contacts
- WHERE vendor_id = ${vendors.id}
- AND contact_email IS NOT NULL
- AND contact_email != ''
- ),
- '[]'::json
- )
- `.as("contact_emails")
- })
- .from(vendors)
- .where(inArray(vendors.id, vendorIds))
-
- // 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수
- function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] {
- const emails: string[] = []
-
- // 벤더 기본 이메일
- if (vendor.email) {
- emails.push(vendor.email)
- }
-
- // 대표자 이메일
- if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) {
- emails.push(vendor.representativeEmail)
- }
-
- // 연락처 이메일들
- if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) {
- vendor.contactEmails.forEach(contactEmail => {
- if (contactEmail && !emails.includes(contactEmail)) {
- emails.push(contactEmail)
- }
- })
- }
-
- return emails.filter(email => email && email.trim() !== '')
- }
-
- const results = []
- const errors = []
-
- // 4. 각 초기 RFQ에 대해 처리
- for (const rfqDetail of initialRfqDetails) {
- try {
- // vendorId null 체크
- if (!rfqDetail.vendorId) {
- errors.push(`벤더 ID가 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
- continue
- }
-
- // 해당 RFQ의 첨부파일들
- const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId)
-
- // 벤더 정보
- const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId)
- if (!vendor) {
- errors.push(`벤더 정보를 찾을 수 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
- continue
- }
-
- // 해당 벤더의 모든 이메일 주소 수집
- const vendorEmails = getAllVendorEmails(vendor)
-
- if (vendorEmails.length === 0) {
- errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`)
- continue
- }
-
- // 5. 기존 vendorAttachmentResponses 조회하여 리비전 상태 확인
- const currentRfqRevision = rfqDetail.rfqRevision || 0
- let emailType: "NEW" | "RESEND" | "REVISION" = "NEW"
- let revisionToUse = currentRfqRevision
-
- // 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용)
- if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
- const existingResponses = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(
- and(
- eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
- eq(vendorAttachmentResponses.rfqType, "INITIAL"),
- eq(vendorAttachmentResponses.rfqRecordId, rfqDetail.initialRfqId)
- )
- )
-
- if (existingResponses.length > 0) {
- // 기존 응답이 있음
- const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0")
-
- if (currentRfqRevision > existingRevision) {
- // RFQ 리비전이 올라감 → 리비전 업데이트
- emailType = "REVISION"
- revisionToUse = currentRfqRevision
- } else {
- // 동일하거나 낮음 → 재전송
- emailType = "RESEND"
- revisionToUse = existingRevision
- }
- } else {
- // 기존 응답이 없음 → 신규 전송
- emailType = "NEW"
- revisionToUse = currentRfqRevision
- }
- }
-
- // 6. vendorAttachmentResponses 레코드 생성/업데이트
- for (const attachment of rfqAttachments) {
- const existingResponse = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(
- and(
- eq(vendorAttachmentResponses.attachmentId, attachment.id),
- eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
- eq(vendorAttachmentResponses.rfqType, "INITIAL")
- )
- )
- .limit(1)
-
- if (existingResponse.length === 0) {
- // 새 응답 레코드 생성
- await db.insert(vendorAttachmentResponses).values({
- attachmentId: attachment.id,
- vendorId: rfqDetail.vendorId,
- rfqType: "INITIAL",
- rfqRecordId: rfqDetail.initialRfqId,
- responseStatus: "NOT_RESPONDED",
- currentRevision: `Rev.${revisionToUse}`,
- requestedAt: new Date(),
- })
- } else {
- // 기존 레코드 업데이트
- await db
- .update(vendorAttachmentResponses)
- .set({
- currentRevision: `Rev.${revisionToUse}`,
- requestedAt: new Date(),
- // 리비전 업데이트인 경우 응답 상태 초기화
- responseStatus: emailType === "REVISION" ? "NOT_RESPONDED" : existingResponse[0].responseStatus,
- })
- .where(eq(vendorAttachmentResponses.id, existingResponse[0].id))
- }
-
- }
-
- const formatDateSafely = (date: Date | string | null | undefined): string => {
- if (!date) return ""
- try {
- // Date 객체로 변환하고 포맷팅
- const dateObj = new Date(date)
- // 유효한 날짜인지 확인
- if (isNaN(dateObj.getTime())) return ""
-
- return dateObj.toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
- })
- } catch (error) {
- console.error("Date formatting error:", error)
- return ""
- }
- }
-
- // 7. 이메일 발송
- const emailData: EmailData = {
- name: vendor.vendorName,
- rfqCode: rfqDetail.rfqCode || "",
- projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용
- projectCompany: rfqDetail.projectCompany || "",
- projectFlag: rfqDetail.projectFlag || "",
- projectSite: rfqDetail.projectSite || "",
- classification: rfqDetail.classification || "ABS",
- incotermsCode: rfqDetail.incotermsCode || "FOB",
- incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port",
- dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "",
- validDate: rfqDetail.validDate ? formatDateSafely(rfqDetail.validDate) : "",
- sparepart: rfqDetail.sparepart || "One(1) year operational spare parts",
- vendorName: vendor.vendorName,
- picName: session.user.name || rfqDetail.picName || "Procurement Manager",
- picEmail: session.user.email || "procurement@samsung.com",
- warrantyPeriod: "Refer to commercial package attached",
- packageName: rfqDetail.packageName || "",
- rfqRevision: revisionToUse, // 리비전 정보 추가
- emailType: emailType, // 이메일 타입 추가
- }
-
- // 이메일 제목 생성 (타입에 따라 다르게)
- let emailSubject = ""
- const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : ""
-
- switch (emailType) {
- case "NEW":
- emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- case "RESEND":
- emailSubject = `[SHI RFQ - RESEND] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- case "REVISION":
- emailSubject = `[SHI RFQ - REVISED] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- }
-
- // nodemailer로 모든 이메일 주소에 한번에 발송
- await sendEmail({
- to: vendorEmails.join(", "), // 콤마+공백으로 구분
- subject: emailSubject,
- template: "initial-rfq-invitation", // hbs 템플릿 파일명
- context: {
- ...emailData,
- language,
- }
- })
-
- // 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용)
- if (rfqDetail.initialRfqId && rfqDetail.rfqId) {
- // Promise.all로 두 테이블 동시 업데이트
- await Promise.all([
- // initialRfq 테이블 업데이트
- db
- .update(initialRfq)
- .set({
- initialRfqStatus: "Init. RFQ Sent",
- updatedAt: new Date(),
- })
- .where(eq(initialRfq.id, rfqDetail.initialRfqId)),
-
- // bRfqs 테이블 status도 함께 업데이트
- db
- .update(bRfqs)
- .set({
- status: "Init. RFQ Sent",
- // updatedBy: session.user.id,
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqDetail.rfqId))
- ]);
- }
-
- results.push({
- initialRfqId: rfqDetail.initialRfqId,
- vendorName: vendor.vendorName,
- vendorEmails: vendorEmails, // 발송된 모든 이메일 주소 기록
- emailCount: vendorEmails.length,
- emailType: emailType,
- rfqRevision: revisionToUse,
- success: true,
- })
-
- } catch (error) {
- console.error(`Error processing RFQ ${rfqDetail.initialRfqId}:`, error)
- errors.push(`RFQ ${rfqDetail.initialRfqId} 처리 중 오류: ${getErrorMessage(error)}`)
- }
- }
-
-
-
- return {
- success: true,
- message: `${results.length}개의 RFQ 이메일이 발송되었습니다.`,
- results,
- errors: errors.length > 0 ? errors : undefined,
- }
-
- } catch (err) {
- console.error("Bulk email error:", err)
- return {
- success: false,
- message: getErrorMessage(err),
- }
- }
-}
-
-// 개별 RFQ 이메일 재발송
-export async function resendInitialRfqEmail(initialRfqId: number) {
- unstable_noStore()
- try {
- const result = await sendBulkInitialRfqEmails({
- initialRfqIds: [initialRfqId],
- language: "en",
- })
-
- return result
- } catch (err) {
- return {
- success: false,
- message: getErrorMessage(err),
- }
- }
-}
-
-export type VendorResponseDetail = VendorAttachmentResponse & {
- attachment: {
- id: number;
- attachmentType: string;
- serialNo: string;
- description: string | null;
- currentRevision: string;
- };
- vendor: {
- id: number;
- vendorCode: string;
- vendorName: string;
- country: string | null;
- businessSize: string | null;
- };
- rfq: {
- id: number;
- rfqCode: string | null;
- description: string | null;
- status: string;
- dueDate: Date;
- };
-};
-
-export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) {
- try {
- // 페이지네이션 설정
- const page = input.page || 1;
- const perPage = input.perPage || 10;
- const offset = (page - 1) * perPage;
-
- // 기본 조건
- let whereConditions = [];
-
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
-
- // RFQ 타입 조건
- // if (input.rfqType !== "ALL") {
- // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
- // }
-
- // 날짜 범위 조건
- if (input.from && input.to) {
- whereConditions.push(
- and(
- gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
- lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
- )
- );
- }
-
- const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 그룹핑된 응답 요약 데이터 조회
- const groupedResponses = await db
- .select({
- vendorId: vendorAttachmentResponses.vendorId,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- rfqType: vendorAttachmentResponses.rfqType,
-
- // 통계 계산 (조건부 COUNT 수정)
- totalAttachments: count(),
- respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
-
- // 날짜 정보
- requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
- lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
-
- // 코멘트 여부
- hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere)
- .groupBy(
- vendorAttachmentResponses.vendorId,
- vendorAttachmentResponses.rfqRecordId,
- vendorAttachmentResponses.rfqType
- )
- .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
- .offset(offset)
- .limit(perPage);
-
- // 벤더 정보와 RFQ 정보를 별도로 조회
- const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
- const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
-
- // 벤더 정보 조회
- const vendorsData = await db.query.vendors.findMany({
- where: or(...vendorIds.map(id => eq(vendors.id, id))),
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- });
-
- // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
- const [initialRfqs] = await Promise.all([
- db.query.initialRfq.findMany({
- where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- }
- }
- }
- })
-
- ]);
-
- // 데이터 조합 및 변환
- const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
- const vendor = vendorsData.find(v => v.id === response.vendorId);
-
- let rfqInfo = null;
- if (response.rfqType === "INITIAL") {
- const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
- rfqInfo = initialRfq?.rfq || null;
- }
-
- // 응답률 계산
- const responseRate = Number(response.totalAttachments) > 0
- ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 완료율 계산 (응답완료 + 포기)
- const completionRate = Number(response.totalAttachments) > 0
- ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 전체 상태 결정
- let overallStatus: ResponseStatus = "NOT_RESPONDED";
- if (Number(response.revisionRequestedCount) > 0) {
- overallStatus = "REVISION_REQUESTED";
- } else if (completionRate === 100) {
- overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
- } else if (Number(response.respondedCount) > 0) {
- overallStatus = "RESPONDED"; // 부분 응답
- }
-
- return {
- id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
- vendorId: response.vendorId,
- rfqRecordId: response.rfqRecordId,
- rfqType: response.rfqType,
- rfq: rfqInfo,
- vendor: vendor || null,
- totalAttachments: Number(response.totalAttachments),
- respondedCount: Number(response.respondedCount),
- pendingCount: Number(response.pendingCount),
- revisionRequestedCount: Number(response.revisionRequestedCount),
- waivedCount: Number(response.waivedCount),
- responseRate,
- completionRate,
- overallStatus,
- requestedAt: response.requestedAt,
- lastRespondedAt: response.lastRespondedAt,
- hasComments: response.hasComments,
- };
- });
-
- // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
- const totalCountResult = await db
- .select({
- totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere);
-
- const totalCount = Number(totalCountResult[0].totalCount);
- const pageCount = Math.ceil(totalCount / perPage);
-
- return {
- data: transformedResponses,
- pageCount,
- totalCount
- };
-
- } catch (err) {
- console.error("getVendorRfqResponses 에러:", err);
- return { data: [], pageCount: 0, totalCount: 0 };
- }
-}
-/**
- * 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용)
- */
-export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) {
- try {
- // 해당 RFQ의 모든 첨부파일 응답 조회
- const responses = await db.query.vendorAttachmentResponses.findMany({
- where: and(
- eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
- eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
- ),
- with: {
- attachment: {
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- // 추가 정보
- picCode: true,
- picName: true,
- EngPicName: true,
- packageNo: true,
- packageName: true,
- projectId: true,
- projectCompany: true,
- projectFlag: true,
- projectSite: true,
- remark: true,
- },
- with: {
- project: {
- columns: {
- id: true,
- code: true,
- name: true,
- type: true,
- }
- }
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- },
- responseAttachments: true,
- },
- orderBy: [asc(vendorAttachmentResponses.attachmentId)]
- });
-
- return {
- data: responses,
- rfqInfo: responses[0]?.attachment?.rfq || null,
- vendorInfo: responses[0]?.vendor || null,
- };
-
- } catch (err) {
- console.error("getRfqAttachmentResponses 에러:", err);
- return { data: [], rfqInfo: null, vendorInfo: null };
- }
-}
-
-export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) {
- try {
- const initial: Record<ResponseStatus, number> = {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- };
-
- // 조건 설정
- let whereConditions = [];
-
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
-
- // RFQ ID 조건
- if (rfqId) {
- const attachmentIds = await db
- .select({ id: bRfqsAttachments.id })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
-
- if (attachmentIds.length > 0) {
- whereConditions.push(
- or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- );
- }
- }
-
- // RFQ 타입 조건
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
-
- const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 상태별 그룹핑 쿼리
- const rows = await db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus);
-
- // 결과 처리
- const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, initial);
-
- return result;
- } catch (err) {
- console.error("getVendorResponseStatusCounts 에러:", err);
- return {} as Record<ResponseStatus, number>;
- }
-}
-
-/**
- * RFQ별 벤더 응답 요약 조회
- */
-export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
-
- try {
- // RFQ의 첨부파일 목록 조회 (relations 사용)
- const attachments = await db.query.bRfqsAttachments.findMany({
- where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
- columns: {
- id: true,
- attachmentType: true,
- serialNo: true,
- description: true,
- }
- });
-
- if (attachments.length === 0) {
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-
- // 조건 설정
- let whereConditions = [
- or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- ];
-
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
-
- const whereCondition = and(...whereConditions);
-
- // 벤더 수 및 응답 통계 조회
- const [vendorStats, statusCounts] = await Promise.all([
- // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
- db
- .select({
- totalVendors: count(),
- respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition),
-
- // 상태별 개수
- db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus)
- ]);
-
- const stats = vendorStats[0];
- const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- });
-
- const responseRate = stats.totalVendors > 0
- ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- const completionRate = stats.totalVendors > 0
- ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- return {
- totalAttachments: attachments.length,
- totalVendors: Number(stats.totalVendors),
- responseRate,
- completionRate,
- statusCounts: statusCountsMap
- };
-
- } catch (err) {
- console.error("getRfqResponseSummary 에러:", err);
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-}
-
-/**
- * 벤더별 응답 진행률 조회
- */
-export async function getVendorResponseProgress(vendorId: string) {
-
- try {
- let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
-
- const whereCondition = and(...whereConditions);
-
- const progress = await db
- .select({
- totalRequests: count(),
- responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition);
- console.log(progress, "progress")
-
- const stats = progress[0];
- const responseRate = Number(stats.totalRequests) > 0
- ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
- : 0;
-
- const completionRate = Number(stats.totalRequests) > 0
- ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
- : 0;
-
- return {
- totalRequests: Number(stats.totalRequests),
- responded: Number(stats.responded),
- pending: Number(stats.pending),
- revisionRequested: Number(stats.revisionRequested),
- waived: Number(stats.waived),
- responseRate,
- completionRate,
- };
-
- } catch (err) {
- console.error("getVendorResponseProgress 에러:", err);
- return {
- totalRequests: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- responseRate: 0,
- completionRate: 0,
- };
- }
-}
-
-
-export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, rfqRecordId: string) {
- try {
- // 1. 벤더 응답 상세 정보 조회 (뷰 사용)
- const responses = await db
- .select()
- .from(vendorResponseDetailView)
- .where(
- and(
- eq(vendorResponseDetailView.vendorId, Number(vendorId)),
- eq(vendorResponseDetailView.rfqRecordId, Number(rfqRecordId))
- )
- )
- .orderBy(asc(vendorResponseDetailView.attachmentId));
-
- // 2. RFQ 진행 현황 요약 조회
- const progressSummaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0))
- .limit(1);
-
- const progressSummary = progressSummaryResult[0] || null;
-
- // 3. 각 응답의 첨부파일 리비전 히스토리 조회
- const attachmentHistories = await Promise.all(
- responses.map(async (response) => {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
- return {
- attachmentId: response.attachmentId,
- revisions: history
- };
- })
- );
-
- // 4. 벤더 응답 파일들 조회 (향상된 정보 포함)
- const responseFiles = await Promise.all(
- responses.map(async (response) => {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
- return {
- responseId: response.responseId,
- files: files
- };
- })
- );
-
- // 5. 데이터 변환 및 통합
- const enhancedResponses = responses.map(response => {
- const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId);
- const responseFileData = responseFiles.find(f => f.responseId === response.responseId);
-
- return {
- ...response,
- // 첨부파일 정보에 리비전 히스토리 추가
- attachment: {
- id: response.attachmentId,
- attachmentType: response.attachmentType,
- serialNo: response.serialNo,
- description: response.attachmentDescription,
- currentRevision: response.currentRevision,
- // 모든 리비전 정보
- revisions: attachmentHistory?.revisions?.map(rev => ({
- id: rev.clientRevisionId,
- revisionNo: rev.clientRevisionNo,
- fileName: rev.clientFileName,
- originalFileName: rev.clientFileName,
- filePath: rev.clientFilePath, // 파일 경로 추가
- fileSize: rev.clientFileSize,
- revisionComment: rev.clientRevisionComment,
- createdAt: rev.clientRevisionCreatedAt?.toISOString() || new Date().toISOString(),
- isLatest: rev.isLatestClientRevision
- })) || []
- },
- // 벤더 응답 파일들
- responseAttachments: responseFileData?.files?.map(file => ({
- id: file.responseAttachmentId,
- fileName: file.fileName,
- originalFileName: file.originalFileName,
- filePath: file.filePath,
- fileSize: file.fileSize,
- description: file.description,
- uploadedAt: file.uploadedAt?.toISOString() || new Date().toISOString(),
- isLatestResponseFile: file.isLatestResponseFile,
- fileSequence: file.fileSequence
- })) || [],
- // 리비전 분석 정보
- isVersionMatched: response.isVersionMatched,
- versionLag: response.versionLag,
- needsUpdate: response.needsUpdate,
- hasMultipleRevisions: response.hasMultipleRevisions,
-
- // 새로 추가된 필드들
- revisionRequestComment: response.revisionRequestComment,
- revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null,
- };
- });
-
- // RFQ 기본 정보 (첫 번째 응답에서 추출)
- const rfqInfo = responses[0] ? {
- id: responses[0].rfqId,
- rfqCode: responses[0].rfqCode,
- // 추가 정보는 기존 방식대로 별도 조회 필요
- description: "",
- dueDate: progressSummary?.dueDate || new Date(),
- status: progressSummary?.rfqStatus || "DRAFT",
- // ... 기타 필요한 정보들
- } : null;
-
- // 벤더 정보
- const vendorInfo = responses[0] ? {
- id: responses[0].vendorId,
- vendorCode: responses[0].vendorCode,
- vendorName: responses[0].vendorName,
- country: responses[0].vendorCountry,
- } : null;
-
- // 통계 정보 계산
- const calculateStats = (responses: typeof enhancedResponses) => {
- const total = responses.length;
- const responded = responses.filter(r => r.responseStatus === "RESPONDED").length;
- const pending = responses.filter(r => r.responseStatus === "NOT_RESPONDED").length;
- const revisionRequested = responses.filter(r => r.responseStatus === "REVISION_REQUESTED").length;
- const waived = responses.filter(r => r.responseStatus === "WAIVED").length;
- const versionMismatch = responses.filter(r => r.effectiveStatus === "VERSION_MISMATCH").length;
- const upToDate = responses.filter(r => r.effectiveStatus === "UP_TO_DATE").length;
-
- return {
- total,
- responded,
- pending,
- revisionRequested,
- waived,
- versionMismatch,
- upToDate,
- responseRate: total > 0 ? Math.round((responded / total) * 100) : 0,
- completionRate: total > 0 ? Math.round(((responded + waived) / total) * 100) : 0,
- versionMatchRate: responded > 0 ? Math.round((upToDate / responded) * 100) : 100
- };
- };
-
- const statistics = calculateStats(enhancedResponses);
-
- return {
- data: enhancedResponses,
- rfqInfo,
- vendorInfo,
- statistics,
- progressSummary: progressSummary ? {
- totalAttachments: progressSummary.totalAttachments,
- attachmentsWithMultipleRevisions: progressSummary.attachmentsWithMultipleRevisions,
- totalClientRevisions: progressSummary.totalClientRevisions,
- totalResponseFiles: progressSummary.totalResponseFiles,
- daysToDeadline: progressSummary.daysToDeadline
- } : null
- };
-
- } catch (err) {
- console.error("getRfqAttachmentResponsesWithRevisions 에러:", err);
- return {
- data: [],
- rfqInfo: null,
- vendorInfo: null,
- statistics: {
- total: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- versionMismatch: 0,
- upToDate: 0,
- responseRate: 0,
- completionRate: 0,
- versionMatchRate: 100
- },
- progressSummary: null
- };
- }
-}
-
-// 첨부파일 리비전 히스토리 조회
-export async function getAttachmentRevisionHistory(attachmentId: number) {
-
- try {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
- return history;
- } catch (err) {
- console.error("getAttachmentRevisionHistory 에러:", err);
- return [];
- }
-}
-
-// RFQ 전체 진행 현황 조회
-export async function getRfqProgressSummary(rfqId: number) {
- try {
- const summaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, rfqId))
- .limit(1);
-
- return summaryResult[0] || null;
- } catch (err) {
- console.error("getRfqProgressSummary 에러:", err);
- return null;
- }
-}
-
-// 벤더 응답 파일 상세 조회 (향상된 정보 포함)
-export async function getVendorResponseFiles(vendorResponseId: number) {
- try {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
- return files;
- } catch (err) {
- console.error("getVendorResponseFiles 에러:", err);
- return [];
- }
-}
-
-
-// 타입 정의 확장
-export type EnhancedVendorResponse = {
- // 기본 응답 정보
- responseId: number;
- rfqId: number;
- rfqCode: string;
- rfqType: "INITIAL" | "FINAL";
- rfqRecordId: number;
-
- // 첨부파일 정보
- attachmentId: number;
- attachmentType: string;
- serialNo: string;
- attachmentDescription?: string;
-
- // 벤더 정보
- vendorId: number;
- vendorCode: string;
- vendorName: string;
- vendorCountry: string;
-
- // 응답 상태
- responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
- currentRevision: string;
- respondedRevision?: string;
- effectiveStatus: string;
-
- // 코멘트 관련 필드들 (새로 추가된 필드 포함)
- responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트
- vendorComment?: string; // 벤더 내부 메모
- revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가)
-
- // 날짜 관련 필드들 (새로 추가된 필드 포함)
- requestedAt: string;
- respondedAt?: string;
- revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가)
-
- // 발주처 최신 리비전 정보
- latestClientRevisionNo?: string;
- latestClientFileName?: string;
- latestClientFileSize?: number;
- latestClientRevisionComment?: string;
-
- // 리비전 분석
- isVersionMatched: boolean;
- versionLag?: number;
- needsUpdate: boolean;
- hasMultipleRevisions: boolean;
-
- // 응답 파일 통계
- totalResponseFiles: number;
- latestResponseFileName?: string;
- latestResponseFileSize?: number;
- latestResponseUploadedAt?: string;
-
- // 첨부파일 정보 (리비전 히스토리 포함)
- attachment: {
- id: number;
- attachmentType: string;
- serialNo: string;
- description?: string;
- currentRevision: string;
- revisions: Array<{
- id: number;
- revisionNo: string;
- fileName: string;
- originalFileName: string;
- filePath?: string;
- fileSize?: number;
- revisionComment?: string;
- createdAt: string;
- isLatest: boolean;
- }>;
- };
-
- // 벤더 응답 파일들
- responseAttachments: Array<{
- id: number;
- fileName: string;
- originalFileName: string;
- filePath: string;
- fileSize?: number;
- description?: string;
- uploadedAt: string;
- isLatestResponseFile: boolean;
- fileSequence: number;
- }>;
-};
-
-
-export async function requestRevision(
- responseId: number,
- revisionReason: string
-): Promise<RequestRevisionResult> {
- try {
- // 입력값 검증
-
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
- const validatedData = requestRevisionSchema.parse({
- responseId,
- revisionReason,
- });
-
- // 현재 응답 정보 조회
- const existingResponse = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
- .limit(1);
-
- if (existingResponse.length === 0) {
- return {
- success: false,
- message: "해당 응답을 찾을 수 없습니다",
- error: "NOT_FOUND",
- };
- }
-
- const response = existingResponse[0];
-
- // 응답 상태 확인 (이미 응답되었거나 포기된 상태에서만 수정 요청 가능)
- if (response.responseStatus !== "RESPONDED") {
- return {
- success: false,
- message: "응답된 상태의 항목에서만 수정을 요청할 수 있습니다",
- error: "INVALID_STATUS",
- };
- }
-
- // 응답 상태를 REVISION_REQUESTED로 업데이트
- const updateResult = await db
- .update(vendorAttachmentResponses)
- .set({
- responseStatus: "REVISION_REQUESTED",
- revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장
- revisionRequestedAt: new Date(), // 수정 요청 시간 저장
- updatedAt: new Date(),
- updatedBy: Number(session.user.id),
- })
- .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
- .returning();
-
- if (updateResult.length === 0) {
- return {
- success: false,
- message: "수정 요청 업데이트에 실패했습니다",
- error: "UPDATE_FAILED",
- };
- }
-
- return {
- success: true,
- message: "수정 요청이 성공적으로 전송되었습니다",
- };
-
- } catch (error) {
- console.error("Request revision server action error:", error);
- return {
- success: false,
- message: "내부 서버 오류가 발생했습니다",
- error: "INTERNAL_ERROR",
- };
- }
-}
-
-
-
-export async function shortListConfirm(input: ShortListConfirmInput) {
- try {
- const validatedInput = shortListConfirmSchema.parse(input)
- const { rfqId, selectedVendorIds, rejectedVendorIds } = validatedInput
-
- // 1. RFQ 정보 조회
- const rfqInfo = await db
- .select()
- .from(bRfqs)
- .where(eq(bRfqs.id, rfqId))
- .limit(1)
-
- if (!rfqInfo.length) {
- return { success: false, message: "RFQ를 찾을 수 없습니다." }
- }
-
- const rfq = rfqInfo[0]
-
- // 2. 기존 initial_rfq에서 필요한 정보 조회
- const initialRfqData = await db
- .select({
- id: initialRfq.id,
- vendorId: initialRfq.vendorId,
- dueDate: initialRfq.dueDate,
- validDate: initialRfq.validDate,
- incotermsCode: initialRfq.incotermsCode,
- gtc: initialRfq.gtc,
- gtcValidDate: initialRfq.gtcValidDate,
- classification: initialRfq.classification,
- sparepart: initialRfq.sparepart,
- cpRequestYn: initialRfq.cpRequestYn,
- prjectGtcYn: initialRfq.prjectGtcYn,
- returnRevision: initialRfq.returnRevision,
- })
- .from(initialRfq)
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, [...selectedVendorIds, ...rejectedVendorIds])
- )
- )
-
- if (!initialRfqData.length) {
- return { success: false, message: "해당 RFQ의 초기 RFQ 데이터를 찾을 수 없습니다." }
- }
-
- // 3. 탈락된 벤더들의 이메일 정보 조회
- let rejectedVendorEmails: Array<{
- vendorId: number
- vendorName: string
- email: string
- }> = []
-
- if (rejectedVendorIds.length > 0) {
- rejectedVendorEmails = await db
- .select({
- vendorId: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- })
- .from(vendors)
- .where(inArray(vendors.id, rejectedVendorIds))
- }
-
- await db.transaction(async (tx) => {
- // 4. 선택된 벤더들에 대해 final_rfq 테이블에 데이터 생성/업데이트
- for (const vendorId of selectedVendorIds) {
- const initialData = initialRfqData.find(data => data.vendorId === vendorId)
-
- if (initialData) {
- // 기존 final_rfq 레코드 확인
- const existingFinalRfq = await tx
- .select()
- .from(finalRfq)
- .where(
- and(
- eq(finalRfq.rfqId, rfqId),
- eq(finalRfq.vendorId, vendorId)
- )
- )
- .limit(1)
-
- if (existingFinalRfq.length > 0) {
- // 기존 레코드 업데이트
- await tx
- .update(finalRfq)
- .set({
- shortList: true,
- finalRfqStatus: "DRAFT",
- dueDate: initialData.dueDate,
- validDate: initialData.validDate,
- incotermsCode: initialData.incotermsCode,
- gtc: initialData.gtc,
- gtcValidDate: initialData.gtcValidDate,
- classification: initialData.classification,
- sparepart: initialData.sparepart,
- cpRequestYn: initialData.cpRequestYn,
- prjectGtcYn: initialData.prjectGtcYn,
- updatedAt: new Date(),
- })
- .where(eq(finalRfq.id, existingFinalRfq[0].id))
- } else {
- // 새 레코드 생성
- await tx
- .insert(finalRfq)
- .values({
- rfqId,
- vendorId,
- finalRfqStatus: "DRAFT",
- dueDate: initialData.dueDate,
- validDate: initialData.validDate,
- incotermsCode: initialData.incotermsCode,
- gtc: initialData.gtc,
- gtcValidDate: initialData.gtcValidDate,
- classification: initialData.classification,
- sparepart: initialData.sparepart,
- shortList: true,
- returnYn: false,
- cpRequestYn: initialData.cpRequestYn,
- prjectGtcYn: initialData.prjectGtcYn,
- returnRevision: 0,
- currency: "KRW",
- taxCode: "VV",
- deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
- firsttimeYn: true,
- materialPriceRelatedYn: false,
- })
- }
- }
- }
-
- // 5. 탈락된 벤더들에 대해서는 shortList: false로 설정 (있다면)
- if (rejectedVendorIds.length > 0) {
- // 기존에 final_rfq에 있는 탈락 벤더들은 shortList를 false로 업데이트
- await tx
- .update(finalRfq)
- .set({
- shortList: false,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(finalRfq.rfqId, rfqId),
- inArray(finalRfq.vendorId, rejectedVendorIds)
- )
- )
- }
-
- // 6. initial_rfq의 shortList 필드도 업데이트
- if (selectedVendorIds.length > 0) {
- await tx
- .update(initialRfq)
- .set({
- shortList: true,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, selectedVendorIds)
- )
- )
- }
-
- if (rejectedVendorIds.length > 0) {
- await tx
- .update(initialRfq)
- .set({
- shortList: false,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, rejectedVendorIds)
- )
- )
- }
- })
-
- // 7. 탈락된 벤더들에게 Letter of Regret 이메일 발송
- const emailErrors: string[] = []
-
- for (const rejectedVendor of rejectedVendorEmails) {
- if (rejectedVendor.email) {
- try {
- await sendEmail({
- to: rejectedVendor.email,
- subject: `Letter of Regret - RFQ ${rfq.rfqCode}`,
- template: "letter-of-regret",
- context: {
- rfqCode: rfq.rfqCode,
- vendorName: rejectedVendor.vendorName,
- projectTitle: rfq.projectTitle || "Project",
- dateTime: new Date().toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "long",
- day: "numeric",
- }),
- companyName: "Your Company Name", // 실제 회사명으로 변경
- language: "ko",
- },
- })
- } catch (error) {
- console.error(`Email sending failed for vendor ${rejectedVendor.vendorName}:`, error)
- emailErrors.push(`${rejectedVendor.vendorName}에게 이메일 발송 실패`)
- }
- }
- }
-
- // 8. 페이지 revalidation
- revalidatePath(`/evcp/a-rfq/${rfqId}`)
- revalidatePath(`/evcp/b-rfq/${rfqId}`)
-
- const successMessage = `Short List가 확정되었습니다. (선택: ${selectedVendorIds.length}개, 탈락: ${rejectedVendorIds.length}개)`
-
- return {
- success: true,
- message: successMessage,
- errors: emailErrors.length > 0 ? emailErrors : undefined,
- data: {
- selectedCount: selectedVendorIds.length,
- rejectedCount: rejectedVendorIds.length,
- emailsSent: rejectedVendorEmails.length - emailErrors.length,
- },
- }
-
- } catch (error) {
- console.error("Short List confirm error:", error)
- return {
- success: false,
- message: "Short List 확정 중 오류가 발생했습니다.",
- }
- }
-}
-
-export async function getFinalRfqDetail(input: GetFinalRfqDetailSchema, rfqId?: number) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: finalRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: finalRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
-
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(finalRfqDetailView.rfqId, rfqId);
- }
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- const rfqCodeCondition = ilike(finalRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
-
- const vendorNameCondition = ilike(finalRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
-
- const vendorCodeCondition = ilike(finalRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
-
- const vendorCountryCondition = ilike(finalRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
-
- const incotermsDescriptionCondition = ilike(finalRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
-
- const paymentTermsDescriptionCondition = ilike(finalRfqDetailView.paymentTermsDescription, s);
- if (paymentTermsDescriptionCondition) validSearchConditions.push(paymentTermsDescriptionCondition);
-
- const classificationCondition = ilike(finalRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
-
- const sparepartCondition = ilike(finalRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
-
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(finalRfqDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(totalResult);
- console.log(total);
-
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof finalRfqDetailView.$inferSelect;
- return sort.desc ? desc(finalRfqDetailView[column]) : asc(finalRfqDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(finalRfqDetailView.createdAt));
- }
-
- const finalRfqData = await db
- .select()
- .from(finalRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: finalRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getFinalRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx
deleted file mode 100644
index 2333d9cf..00000000
--- a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx
+++ /dev/null
@@ -1,523 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { format } from "date-fns"
-import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
-import { cn } from "@/lib/utils"
-import { toast } from "sonner"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import { createRfqAction, previewNextRfqCode } from "../service"
-
-export type Project = {
- id: number;
- projectCode: string;
- projectName: string;
-}
-
-// 클라이언트 폼 스키마 (projectId 필수로 변경)
-const createRfqSchema = z.object({
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경
- dueDate: z.date({
- required_error: "마감일을 선택해주세요",
- }),
- picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"),
- picName: z.string().optional(),
- engPicName: z.string().optional(),
- packageNo: z.string().min(1, "패키지 번호를 입력해주세요"),
- packageName: z.string().min(1, "패키지명을 입력해주세요"),
- remark: z.string().optional(),
- projectCompany: z.string().optional(),
- projectFlag: z.string().optional(),
- projectSite: z.string().optional(),
-})
-
-type CreateRfqFormValues = z.infer<typeof createRfqSchema>
-
-interface CreateRfqDialogProps {
- onSuccess?: () => void;
-}
-
-export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState<string>("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
- const router = useRouter()
- const { data: session } = useSession()
-
- const userId = React.useMemo(() => {
- return session?.user?.id ? Number(session.user.id) : null;
- }, [session]);
-
- const form = useForm<CreateRfqFormValues>({
- resolver: zodResolver(createRfqSchema),
- defaultValues: {
- projectId: undefined,
- dueDate: undefined,
- picCode: "",
- picName: "",
- engPicName: "",
- packageNo: "",
- packageName: "",
- remark: "",
- projectCompany: "",
- projectFlag: "",
- projectSite: "",
- },
- })
-
- // picCode 변경 시 미리보기 업데이트
- const watchedPicCode = form.watch("picCode")
-
- React.useEffect(() => {
- if (watchedPicCode && watchedPicCode.length > 0) {
- setIsLoadingPreview(true)
- const timer = setTimeout(async () => {
- try {
- const preview = await previewNextRfqCode(watchedPicCode)
- setPreviewCode(preview)
- } catch (error) {
- console.error("미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- }, 500) // 500ms 디바운스
-
- return () => clearTimeout(timer)
- } else {
- setPreviewCode("")
- }
- }, [watchedPicCode])
-
- // 다이얼로그 열림/닫힘 처리 및 폼 리셋
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen)
-
- // 다이얼로그가 닫힐 때 폼과 상태 초기화
- if (!newOpen) {
- form.reset()
- setPreviewCode("")
- setIsLoadingPreview(false)
- }
- }
-
- const handleCancel = () => {
- form.reset()
- setOpen(false)
- }
-
-
- const onSubmit = async (data: CreateRfqFormValues) => {
- if (!userId) {
- toast.error("로그인이 필요합니다")
- return
- }
-
- setIsLoading(true)
-
- try {
- // 서버 액션 호출 - Date 객체를 직접 전달
- const result = await createRfqAction({
- projectId: data.projectId, // 이제 항상 값이 있음
- dueDate: data.dueDate, // Date 객체 직접 전달
- picCode: data.picCode,
- picName: data.picName || "",
- engPicName: data.engPicName || "",
- packageNo: data.packageNo,
- packageName: data.packageName,
- remark: data.remark || "",
- projectCompany: data.projectCompany || "",
- projectFlag: data.projectFlag || "",
- projectSite: data.projectSite || "",
- createdBy: userId,
- updatedBy: userId,
- })
-
- if (result.success) {
- toast.success(result.message, {
- description: `RFQ 코드: ${result.data?.rfqCode}`,
- })
-
- // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨)
- setOpen(false)
-
- // 성공 콜백 실행
- if (onSuccess) {
- onSuccess()
- }
-
- } else {
- toast.error(result.error || "RFQ 생성에 실패했습니다")
- }
-
- } catch (error) {
- console.error('RFQ 생성 오류:', error)
- toast.error("RFQ 생성에 실패했습니다", {
- description: "알 수 없는 오류가 발생했습니다",
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleProjectSelect = (project: Project | null) => {
- if (project === null) {
- form.setValue("projectId", undefined as any); // 타입 에러 방지
- return;
- }
- form.setValue("projectId", project.id);
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button size="sm" variant="outline">
- <Plus className="mr-2 h-4 w-4" />
- 새 RFQ
- </Button>
- </DialogTrigger>
- <DialogContent className="max-w-3xl h-[90vh] flex flex-col">
- {/* 고정된 헤더 */}
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>새 RFQ 생성</DialogTitle>
- <DialogDescription>
- 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
-
- {/* 스크롤 가능한 컨텐츠 영역 */}
- <div className="flex-1 overflow-y-auto px-1">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2">
-
- {/* 프로젝트 선택 (필수) */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 프로젝트 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 마감일 (필수) */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>
- 마감일 <span className="text-red-500">*</span>
- </FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "yyyy-MM-dd")
- ) : (
- <span>마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 구매 담당자 코드 (필수) + 미리보기 */}
- <FormField
- control={form.control}
- name="picCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 구매 담당자 코드 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <div className="space-y-2">
- <Input
- placeholder="예: P001, P002, MGR01 등"
- {...field}
- />
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2 p-2 bg-muted rounded-md">
- <Eye className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm text-muted-foreground">
- 생성될 RFQ 코드:
- </span>
- <Badge variant="outline" className="font-mono">
- {isLoadingPreview ? "생성 중..." : previewCode}
- </Badge>
- </div>
- )}
- </div>
- </FormControl>
- <FormDescription>
- RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 담당자 정보 (두 개 나란히) */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">담당자 정보</h4>
- <div className="grid grid-cols-2 gap-4">
- {/* 구매 담당자 */}
- <FormField
- control={form.control}
- name="picName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구매 담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="구매 담당자명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설계 담당자 */}
- <FormField
- control={form.control}
- name="engPicName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설계 담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="설계 담당자명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* 패키지 정보 (두 개 나란히) - 필수 */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">패키지 정보</h4>
- <div className="grid grid-cols-2 gap-4">
- {/* 패키지 번호 (필수) */}
- <FormField
- control={form.control}
- name="packageNo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 패키지 번호 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="패키지 번호"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지명 (필수) */}
- <FormField
- control={form.control}
- name="packageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 패키지명 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="패키지명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* 프로젝트 상세 정보 */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">프로젝트 상세 정보</h4>
- <div className="grid grid-cols-1 gap-3">
- <FormField
- control={form.control}
- name="projectCompany"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 회사</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 회사명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-3">
- <FormField
- control={form.control}
- name="projectFlag"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 플래그</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 플래그"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="projectSite"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 사이트</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 사이트"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- </div>
-
- {/* 비고 */}
- <FormField
- control={form.control}
- name="remark"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 비고사항을 입력하세요"
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </form>
- </Form>
- </div>
-
- {/* 고정된 푸터 */}
- <DialogFooter className="flex-shrink-0">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- onClick={form.handleSubmit(onSubmit)}
- disabled={isLoading}
- >
- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {isLoading ? "생성 중..." : "RFQ 생성"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx
deleted file mode 100644
index af5c22b2..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-columns.tsx
+++ /dev/null
@@ -1,499 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Eye, Calendar, AlertTriangle, CheckCircle2, Clock, FileText } from "lucide-react"
-
-import { formatDate, cn } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Progress } from "@/components/ui/progress"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { useRouter } from "next/navigation"
-import { RfqDashboardView } from "@/db/schema"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetRFQColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqDashboardView> | null>>;
- router: NextRouter;
-}
-
-// 상태에 따른 Badge 변형 결정 함수
-function getStatusBadge(status: string) {
- switch (status) {
- case "DRAFT":
- return { variant: "outline" as const, label: "초안" };
- case "Doc. Received":
- return { variant: "secondary" as const, label: "문서접수" };
- case "PIC Assigned":
- return { variant: "secondary" as const, label: "담당자배정" };
- case "Doc. Confirmed":
- return { variant: "default" as const, label: "문서확정" };
- case "Init. RFQ Sent":
- return { variant: "default" as const, label: "초기RFQ발송" };
- case "Init. RFQ Answered":
- return { variant: "default" as const, label: "초기RFQ회신" };
- case "TBE started":
- return { variant: "secondary" as const, label: "TBE시작" };
- case "TBE finished":
- return { variant: "secondary" as const, label: "TBE완료" };
- case "Final RFQ Sent":
- return { variant: "default" as const, label: "최종RFQ발송" };
- case "Quotation Received":
- return { variant: "default" as const, label: "견적접수" };
- case "Vendor Selected":
- return { variant: "success" as const, label: "업체선정" };
- default:
- return { variant: "outline" as const, label: status };
- }
-}
-
-function getProgressBadge(progress: number) {
- if (progress >= 100) {
- return { variant: "success" as const, label: "완료" };
- } else if (progress >= 70) {
- return { variant: "default" as const, label: "진행중" };
- } else if (progress >= 30) {
- return { variant: "secondary" as const, label: "초기진행" };
- } else {
- return { variant: "outline" as const, label: "시작" };
- }
-}
-
-function getUrgencyLevel(daysToDeadline: number): "high" | "medium" | "low" {
- if (daysToDeadline <= 3) return "high";
- if (daysToDeadline <= 7) return "medium";
- return "low";
-}
-
-export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): ColumnDef<RfqDashboardView>[] {
-
- // Select 컬럼
- const selectColumn: ColumnDef<RfqDashboardView> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- };
-
- // RFQ 코드 컬럼
- const rfqCodeColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
- ),
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.getValue("rfqCode")}</span>
- {row.original.description && (
- <span className="text-xs text-muted-foreground truncate max-w-[200px]">
- {row.original.description}
- </span>
- )}
- </div>
- ),
- };
-
- // 프로젝트 정보 컬럼
- const projectColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트" />
- ),
- cell: ({ row }) => {
- const projectName = row.original.projectName;
- const projectCode = row.original.projectCode;
-
- if (!projectName) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{projectName}</span>
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
- {projectCode && <span>{projectCode}</span>}
- </div>
- </div>
- );
- },
- };
-
- // 패키지 정보 컬럼
- const packageColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "packageNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="패키지" />
- ),
- cell: ({ row }) => {
- const packageNo = row.original.packageNo;
- const packageName = row.original.packageName;
-
- if (!packageNo) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{packageNo}</span>
- {packageName && (
- <span className="text-xs text-muted-foreground truncate max-w-[150px]">
- {packageName}
- </span>
- )}
- </div>
- );
- },
- };
-
- const updatedColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "updatedBy",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Updated By" />
- ),
- cell: ({ row }) => {
- const updatedByName = row.original.updatedByName;
- const updatedByEmail = row.original.updatedByEmail;
-
- if (!updatedByName) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{updatedByName}</span>
- {updatedByEmail && (
- <span className="text-xs text-muted-foreground truncate max-w-[150px]">
- {updatedByEmail}
- </span>
- )}
- </div>
- );
- },
- };
-
-
- // 상태 컬럼
- const statusColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const statusBadge = getStatusBadge(row.original.status);
- return <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>;
- },
- filterFn: (row, id, value) => {
- return value.includes(row.getValue(id));
- },
- };
-
- // 진행률 컬럼
- const progressColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "overallProgress",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="진행률" />
- ),
- cell: ({ row }) => {
- const progress = row.original.overallProgress;
- const progressBadge = getProgressBadge(progress);
-
- return (
- <div className="flex flex-col gap-1 min-w-[120px]">
- <div className="flex items-center justify-between">
- <span className="text-sm font-medium">{progress}%</span>
- <Badge variant={progressBadge.variant} className="text-xs">
- {progressBadge.label}
- </Badge>
- </div>
- <Progress value={progress} className="h-2" />
- </div>
- );
- },
- };
-
- // 마감일 컬럼
- const dueDateColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.original.dueDate;
- const daysToDeadline = row.original.daysToDeadline;
- const urgencyLevel = getUrgencyLevel(daysToDeadline);
-
- if (!dueDate) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4 text-muted-foreground" />
- <span>{formatDate(dueDate, 'KR')}</span>
- </div>
- <div className="flex items-center gap-1 text-xs">
- {urgencyLevel === "high" && (
- <AlertTriangle className="h-3 w-3 text-red-500" />
- )}
- {urgencyLevel === "medium" && (
- <Clock className="h-3 w-3 text-yellow-500" />
- )}
- {urgencyLevel === "low" && (
- <CheckCircle2 className="h-3 w-3 text-green-500" />
- )}
- <span className={cn(
- urgencyLevel === "high" && "text-red-500",
- urgencyLevel === "medium" && "text-yellow-600",
- urgencyLevel === "low" && "text-green-600"
- )}>
- {daysToDeadline > 0 ? `${daysToDeadline}일 남음` :
- daysToDeadline === 0 ? "오늘 마감" :
- `${Math.abs(daysToDeadline)}일 지남`}
- </span>
- </div>
- </div>
- );
- },
- };
-
- // 담당자 컬럼
- const picColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "picName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="구매 담당자" />
- ),
- cell: ({ row }) => {
- const picName = row.original.picName;
- return picName ? (
- <span>{picName}</span>
- ) : (
- <span className="text-muted-foreground">미배정</span>
- );
- },
- };
-
- const engPicColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "engPicName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="설계 담당자" />
- ),
- cell: ({ row }) => {
- const picName = row.original.engPicName;
- return picName ? (
- <span>{picName}</span>
- ) : (
- <span className="text-muted-foreground">미배정</span>
- );
- },
- };
-
-
- const pjtCompanyColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectCompany",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" />
- ),
- cell: ({ row }) => {
- const projectCompany = row.original.projectCompany;
- return projectCompany ? (
- <span>{projectCompany}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
- const pjtFlagColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectFlag",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" />
- ),
- cell: ({ row }) => {
- const projectFlag = row.original.projectFlag;
- return projectFlag ? (
- <span>{projectFlag}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
-
- const pjtSiteColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectSite",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" />
- ),
- cell: ({ row }) => {
- const projectSite = row.original.projectSite;
- return projectSite ? (
- <span>{projectSite}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
- const remarkColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => {
- const remark = row.original.remark;
- return remark ? (
- <span>{remark}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
- // 첨부파일 수 컬럼
- const attachmentColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "totalAttachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const count = row.original.totalAttachments;
- return (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <span>{count}</span>
- </div>
- );
- },
- };
-
- // 벤더 현황 컬럼
- const vendorStatusColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "initialVendorCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 현황" />
- ),
- cell: ({ row }) => {
- const initial = row.original.initialVendorCount;
- const final = row.original.finalVendorCount;
- const initialRate = row.original.initialResponseRate;
- const finalRate = row.original.finalResponseRate;
-
- return (
- <div className="flex flex-col gap-1 text-xs">
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground">초기:</span>
- <span>{initial}개사 ({Number(initialRate).toFixed(0)}%)</span>
- </div>
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground">최종:</span>
- <span>{final}개사 ({Number(finalRate).toFixed(0)}%)</span>
- </div>
- </div>
- );
- },
- };
-
- // 생성일 컬럼
- const createdAtColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="생성일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.createdAt as Date;
- return formatDate(dateVal, 'KR');
- },
- };
-
- const updatedAtColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.updatedAt as Date;
- return formatDate(dateVal, 'KR');
- },
- };
-
- // Actions 컬럼
- const actionsColumn: ColumnDef<RfqDashboardView> = {
- id: "detail",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상세내용" />
- ),
- // enableHiding: false,
- cell: function Cell({ row }) {
- const rfq = row.original;
- const detailUrl = `/evcp/b-rfq/${rfq.rfqId}/initial`;
-
- return (
-
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- onClick={() => router.push(detailUrl)}
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- );
- },
- size: 40,
- };
-
- return [
- selectColumn,
- rfqCodeColumn,
- projectColumn,
- packageColumn,
- statusColumn,
- picColumn,
- progressColumn,
- dueDateColumn,
- actionsColumn,
-
- engPicColumn,
-
- pjtCompanyColumn,
- pjtFlagColumn,
- pjtSiteColumn,
-
- attachmentColumn,
- vendorStatusColumn,
- createdAtColumn,
-
- updatedAtColumn,
- updatedColumn,
- remarkColumn
- ];
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx
deleted file mode 100644
index ff3bc132..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx
+++ /dev/null
@@ -1,617 +0,0 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { getFiltersStateParser } from "@/lib/parsers"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// RFQ 필터 스키마 정의
-const rfqFilterSchema = z.object({
- rfqCode: z.string().optional(),
- projectCode: z.string().optional(),
- picName: z.string().optional(),
- packageNo: z.string().optional(),
- packageName: z.string().optional(),
- status: z.string().optional(),
-})
-
-// RFQ 상태 옵션 정의
-const rfqStatusOptions = [
- { value: "DRAFT", label: "초안" },
- { value: "Doc. Received", label: "문서접수" },
- { value: "PIC Assigned", label: "담당자배정" },
- { value: "Doc. Confirmed", label: "문서확인" },
- { value: "Init. RFQ Sent", label: "초기RFQ발송" },
- { value: "Init. RFQ Answered", label: "초기RFQ회신" },
- { value: "TBE started", label: "TBE시작" },
- { value: "TBE finished", label: "TBE완료" },
- { value: "Final RFQ Sent", label: "최종RFQ발송" },
- { value: "Quotation Received", label: "견적접수" },
- { value: "Vendor Selected", label: "업체선정" },
-]
-
-type RFQFilterFormValues = z.infer<typeof rfqFilterSchema>
-
-interface RFQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-export function RFQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: RFQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<RFQFilterFormValues>({
- resolver: zodResolver(rfqFilterSchema),
- defaultValues: {
- rfqCode: "",
- projectCode: "",
- picName: "",
- packageNo: "",
- packageName: "",
- status: "",
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id in formValues) {
- // @ts-ignore - 동적 필드 접근
- formValues[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen])
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
- // 폼 제출 핸들러
- async function onSubmit(data: RFQFilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.rfqCode?.trim()) {
- newFilters.push({
- id: "rfqCode",
- value: data.rfqCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.projectCode?.trim()) {
- newFilters.push({
- id: "projectCode",
- value: data.projectCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.picName?.trim()) {
- newFilters.push({
- id: "picName",
- value: data.picName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.packageNo?.trim()) {
- newFilters.push({
- id: "packageNo",
- value: data.packageNo.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.packageName?.trim()) {
- newFilters.push({
- id: "packageName",
- value: data.packageName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 수동으로 URL 업데이트 (nuqs 대신)
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 기존 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('rfqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('rfqBasicJoinOperator');
- params.delete('page');
-
- // 새로운 필터 추가
- if (newFilters.length > 0) {
- params.set('basicFilters', JSON.stringify(newFilters));
- params.set('basicJoinOperator', joinOperator);
- }
-
- // 페이지를 1로 설정
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("New RFQ Filter URL:", newUrl);
-
- // 페이지 완전 새로고침으로 서버 렌더링 강제
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- if (onSearch) {
- console.log("Calling RFQ onSearch...");
- onSearch();
- }
-
- console.log("=== RFQ Filter Submit Complete ===");
- } catch (error) {
- console.error("RFQ 필터 적용 오류:", error);
- }
- })
- }
-
- // 필터 초기화 핸들러
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- rfqCode: "",
- projectCode: "",
- picName: "",
- packageNo: "",
- packageName: "",
- status: "",
- });
-
- console.log("=== RFQ Filter Reset Debug ===");
- console.log("Current URL before reset:", window.location.href);
-
- // 수동으로 URL 초기화
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('rfqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('rfqBasicJoinOperator');
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("Reset URL:", newUrl);
-
- // 페이지 완전 새로고침
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("RFQ 필터 초기화 완료");
- setIsInitializing(false);
- } catch (error) {
- console.error("RFQ 필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">RFQ 검색 필터</h3>
- <div className="flex items-center gap-2">
- {getActiveFilterCount() > 0 && (
- <Badge variant="secondary" className="px-2 py-1">
- {getActiveFilterCount()}개 필터 적용됨
- </Badge>
- )}
- </div>
- </div>
-
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-4 pt-2">
-
- {/* RFQ 코드 */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 코드</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="RFQ 코드 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("rfqCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트 코드 */}
- <FormField
- control={form.control}
- name="projectCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 코드</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="프로젝트 코드 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("projectCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 담당자명 */}
- <FormField
- control={form.control}
- name="picName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="담당자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("picName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지 번호 */}
- <FormField
- control={form.control}
- name="packageNo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>패키지 번호</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="패키지 번호 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("packageNo", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지명 */}
- <FormField
- control={form.control}
- name="packageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>패키지명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="패키지명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("packageName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 상태 */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 상태</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="RFQ 상태 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {rfqStatusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- 초기화
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? "조회 중..." : "조회"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx
deleted file mode 100644
index 02ba4aaa..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, FileText, Mail, Search } from "lucide-react"
-import { useRouter } from "next/navigation"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { RfqDashboardView } from "@/db/schema"
-import { CreateRfqDialog } from "./add-new-rfq-dialog"
-
-interface RFQTableToolbarActionsProps {
- table: Table<RfqDashboardView>
-}
-
-export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행 정보
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedCount = selectedRows.length
- const isSingleSelected = selectedCount === 1
-
- // RFQ 문서 확인 핸들러
- const handleDocumentCheck = () => {
- if (isSingleSelected) {
- const selectedRfq = selectedRows[0].original
- const rfqId = selectedRfq.rfqId
-
- // RFQ 첨부문서 확인 페이지로 이동
- router.push(`/evcp/b-rfq/${rfqId}`)
- }
- }
-
- // 테이블 새로고침 핸들러
- const handleRefresh = () => {
- // 페이지 새로고침 또는 데이터 다시 fetch
- router.refresh()
- }
-
- return (
- <div className="flex items-center gap-2">
- {/* 새 RFQ 생성 다이얼로그 */}
- <CreateRfqDialog onSuccess={handleRefresh} />
-
- {/* RFQ 문서 확인 버튼 - 단일 선택시만 활성화 */}
- <Button
- size="sm"
- variant="outline"
- onClick={handleDocumentCheck}
- disabled={!isSingleSelected}
- className="flex items-center"
- >
- <Search className="mr-2 h-4 w-4" />
- RFQ 문서 확인
- </Button>
-
-
- </div>
- )
-}
diff --git a/lib/b-rfq/summary-table/summary-rfq-table.tsx b/lib/b-rfq/summary-table/summary-rfq-table.tsx
deleted file mode 100644
index 83d50685..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-table.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getRFQDashboard } from "../service"
-import { cn } from "@/lib/utils"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
-import { getRFQColumns } from "./summary-rfq-columns"
-import { RfqDashboardView } from "@/db/schema"
-import { RFQTableToolbarActions } from "./summary-rfq-table-toolbar-actions"
-import { RFQFilterSheet } from "./summary-rfq-filter-sheet"
-
-interface RFQDashboardTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getRFQDashboard>>]>
- className?: string
-}
-
-export function RFQDashboardTable({ promises, className }: RFQDashboardTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDashboardView> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- const router = useRouter()
- const searchParams = useSearchParams()
-
- const containerRef = React.useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = React.useState(0)
-
- const updateContainerBounds = React.useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- setContainerTop(rect.top)
- }
- }, [])
-
- React.useEffect(() => {
- updateContainerBounds()
-
- const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', updateContainerBounds)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', updateContainerBounds)
- }
- }, [updateContainerBounds])
-
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- console.log("RFQ Dashboard Table Data:", {
- dataLength: tableData.data?.length,
- pageCount: tableData.pageCount,
- total: tableData.total,
- sampleData: tableData.data?.[0]
- })
-
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') ?
- JSON.parse(searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- updateClientState,
- getCurrentSettings,
- } = useTablePresets<RfqDashboardView>('rfq-dashboard-table', initialSettings)
-
- const columns = React.useMemo(
- () => getRFQColumns({ setRowAction, router }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<RfqDashboardView>[] = [
- { id: "rfqCode", label: "RFQ 코드" },
- { id: "projectName", label: "프로젝트" },
- { id: "status", label: "상태" },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<RfqDashboardView>[] = [
- { id: "rfqCode", label: "RFQ 코드", type: "text" },
- { id: "description", label: "설명", type: "text" },
- { id: "projectName", label: "프로젝트명", type: "text" },
- { id: "projectCode", label: "프로젝트 코드", type: "text" },
- { id: "packageNo", label: "패키지 번호", type: "text" },
- { id: "packageName", label: "패키지명", type: "text" },
- { id: "picName", label: "담당자", type: "text" },
- { id: "status", label: "상태", type: "select", options: [
- { label: "초안", value: "DRAFT" },
- { label: "문서접수", value: "Doc. Received" },
- { label: "담당자배정", value: "PIC Assigned" },
- { label: "문서확인", value: "Doc. Confirmed" },
- { label: "초기RFQ발송", value: "Init. RFQ Sent" },
- { label: "초기RFQ회신", value: "Init. RFQ Answered" },
- { label: "TBE시작", value: "TBE started" },
- { label: "TBE완료", value: "TBE finished" },
- { label: "최종RFQ발송", value: "Final RFQ Sent" },
- { label: "견적접수", value: "Quotation Received" },
- { label: "업체선정", value: "Vendor Selected" },
- ]},
- { id: "overallProgress", label: "진행률", type: "number" },
- { id: "dueDate", label: "마감일", type: "date" },
- { id: "createdAt", label: "생성일", type: "date" },
- ]
-
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.rfqId),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
- }
- }
-
- const FILTER_PANEL_WIDTH = 400;
-
- return (
- <>
- {/* Filter Panel */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- <div className="h-full">
- <RFQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
- <div className="flex w-full h-full">
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
- }}
- >
- {/* Header Bar */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<RfqDashboardView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <RFQTableToolbarActions table={table} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- </div>
- </div>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts
deleted file mode 100644
index bee10a11..00000000
--- a/lib/b-rfq/validations.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,parseAsBoolean
- } from "nuqs/server"
- import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { FinalRfqDetailView, VendorAttachmentResponse } from "@/db/schema";
-
-export const searchParamsRFQDashboardCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - rfqDashboardView 기반
- sort: getSortingStateParser<{
- rfqId: number;
- rfqCode: string;
- description: string;
- status: string;
- dueDate: Date;
- projectCode: string;
- projectName: string;
- packageNo: string;
- packageName: string;
- picName: string;
- totalAttachments: number;
- initialVendorCount: number;
- finalVendorCount: number;
- initialResponseRate: number;
- finalResponseRate: number;
- overallProgress: number;
- daysToDeadline: number;
- createdAt: Date;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- rfqBasicFilters: getFiltersStateParser().withDefault([]),
- rfqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // RFQ 특화 필터
- rfqCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- picName: parseAsString.withDefault(""),
- packageNo: parseAsString.withDefault(""),
- status: parseAsStringEnum([
- "DRAFT",
- "Doc. Received",
- "PIC Assigned",
- "Doc. Confirmed",
- "Init. RFQ Sent",
- "Init. RFQ Answered",
- "TBE started",
- "TBE finished",
- "Final RFQ Sent",
- "Quotation Received",
- "Vendor Selected"
- ]),
- dueDateFrom: parseAsString.withDefault(""),
- dueDateTo: parseAsString.withDefault(""),
- progressMin: parseAsInteger.withDefault(0),
- progressMax: parseAsInteger.withDefault(100),
- });
-
- export type GetRFQDashboardSchema = Awaited<ReturnType<typeof searchParamsRFQDashboardCache.parse>>
-
-
- export const createRfqServerSchema = z.object({
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경
- dueDate: z.date(), // Date 객체로 직접 받기
- picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"),
- picName: z.string().optional(),
- engPicName: z.string().optional(),
- packageNo: z.string().min(1, "패키지 번호를 입력해주세요"),
- packageName: z.string().min(1, "패키지명을 입력해주세요"),
- remark: z.string().optional(),
- projectCompany: z.string().optional(),
- projectFlag: z.string().optional(),
- projectSite: z.string().optional(),
- createdBy: z.number(),
- updatedBy: z.number(),
- })
-
- export type CreateRfqInput = z.infer<typeof createRfqServerSchema>
-
-
-
- export type RfqAttachment = {
- id: number
- attachmentType: string
- serialNo: string
- rfqId: number
- fileName: string
- originalFileName: string
- filePath: string
- fileSize: number | null
- fileType: string | null
- description: string | null
- createdBy: number
- createdAt: Date
- createdByName?: string
- responseStats?: {
- totalVendors: number
- respondedCount: number
- pendingCount: number
- waivedCount: number
- responseRate: number
- }
- }
-
- // RFQ Attachments용 검색 파라미터 캐시
- export const searchParamsRfqAttachmentsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RfqAttachment>().withDefault([
- { id: "createdAt", desc: true },
- ]),
- // 기본 필터
- attachmentType: parseAsArrayOf(z.string()).withDefault([]),
- fileType: parseAsArrayOf(z.string()).withDefault([]),
- search: parseAsString.withDefault(""),
- // advanced filter
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- })
-
- // 스키마 타입들
- export type GetRfqAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>
-
-
- // 첨부파일 레코드 타입
-export const attachmentRecordSchema = z.object({
- rfqId: z.number().positive(),
- attachmentType: z.enum(["구매", "설계"]),
- // serialNo: z.string().min(1),
- description: z.string().optional(),
- fileName: z.string(),
- originalFileName: z.string(),
- filePath: z.string(),
- fileSize: z.number(),
- fileType: z.string(),
-})
-
-export type AttachmentRecord = z.infer<typeof attachmentRecordSchema>
-
-export const deleteAttachmentsSchema = z.object({
- ids: z.array(z.number()).min(1, "삭제할 첨부파일을 선택해주세요."),
-})
-
-export type DeleteAttachmentsInput = z.infer<typeof deleteAttachmentsSchema>
-
-
-//Inital RFQ
-export const searchParamsInitialRfqDetailCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - initialRfqDetailView 기반
- sort: getSortingStateParser<{
- rfqId: number;
- rfqCode: string;
- rfqStatus: string;
- initialRfqId: number;
- initialRfqStatus: string;
- vendorId: number;
- vendorCode: string;
- vendorName: string;
- vendorCountry: string;
- vendorBusinessSize: string;
- dueDate: Date;
- validDate: Date;
- incotermsCode: string;
- incotermsDescription: string;
- shortList: boolean;
- returnYn: boolean;
- cpRequestYn: boolean;
- prjectGtcYn: boolean;
- returnRevision: number;
- gtc: string;
- gtcValidDate: string;
- classification: string;
- sparepart: string;
- createdAt: Date;
- updatedAt: Date;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // Initial RFQ Detail 특화 필터
- rfqCode: parseAsString.withDefault(""),
- rfqStatus: parseAsStringEnum([
- "DRAFT",
- "Doc. Received",
- "PIC Assigned",
- "Doc. Confirmed",
- "Init. RFQ Sent",
- "Init. RFQ Answered",
- "TBE started",
- "TBE finished",
- "Final RFQ Sent",
- "Quotation Received",
- "Vendor Selected"
- ]),
- initialRfqStatus: parseAsStringEnum([
- "PENDING",
- "SENT",
- "RESPONDED",
- "EXPIRED",
- "CANCELLED"
- ]),
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- vendorCountry: parseAsString.withDefault(""),
- vendorBusinessSize: parseAsStringEnum([
- "LARGE",
- "MEDIUM",
- "SMALL",
- "STARTUP"
- ]),
- incotermsCode: parseAsString.withDefault(""),
- dueDateFrom: parseAsString.withDefault(""),
- dueDateTo: parseAsString.withDefault(""),
- validDateFrom: parseAsString.withDefault(""),
- validDateTo: parseAsString.withDefault(""),
- shortList: parseAsStringEnum(["true", "false"]),
- returnYn: parseAsStringEnum(["true", "false"]),
- cpRequestYn: parseAsStringEnum(["true", "false"]),
- prjectGtcYn: parseAsStringEnum(["true", "false"]),
- classification: parseAsString.withDefault(""),
- sparepart: parseAsString.withDefault(""),
-});
-
-export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>;
-
-
-
-export const updateInitialRfqSchema = z.object({
- initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"]),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- validDate: z.date().optional(),
- gtc: z.string().optional(),
- gtcValidDate: z.string().optional(),
- incotermsCode: z.string().max(20, "Incoterms 코드는 20자 이하여야 합니다.").optional(),
- classification: z.string().max(255, "분류는 255자 이하여야 합니다.").optional(),
- sparepart: z.string().max(255, "예비부품은 255자 이하여야 합니다.").optional(),
- shortList: z.boolean().default(false),
- returnYn: z.boolean().default(false),
- cpRequestYn: z.boolean().default(false),
- prjectGtcYn: z.boolean().default(false),
- rfqRevision: z.number().int().min(0, "RFQ 리비전은 0 이상이어야 합니다.").default(0),
-})
-
-export const removeInitialRfqsSchema = z.object({
- ids: z.array(z.number()).min(1, "최소 하나의 항목을 선택해주세요."),
-})
-
-export type UpdateInitialRfqSchema = z.infer<typeof updateInitialRfqSchema>
-export type RemoveInitialRfqsSchema = z.infer<typeof removeInitialRfqsSchema>
-
-// 벌크 이메일 발송 스키마
-export const bulkEmailSchema = z.object({
- initialRfqIds: z.array(z.number()).min(1, "최소 하나의 초기 RFQ를 선택해주세요."),
- language: z.enum(["en", "ko"]).default("en"),
-})
-
-export type BulkEmailInput = z.infer<typeof bulkEmailSchema>
-
-// 검색 파라미터 캐시 설정
-
-export type ResponseStatus = "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
-export type RfqType = "INITIAL" | "FINAL";
-
-
-export type VendorRfqResponseColumns = {
- id: string;
- vendorId: number;
- rfqRecordId: number;
- rfqType: RfqType;
- overallStatus: ResponseStatus;
- totalAttachments: number;
- respondedCount: number;
- pendingCount: number;
- responseRate: number;
- completionRate: number;
- requestedAt: Date;
- lastRespondedAt: Date | null;
-};
-
-// 검색 파라미터 캐시 설정
-export const searchParamsVendorResponseCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<VendorRfqResponseColumns>().withDefault([
- { id: "requestedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 및 필터
- search: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["INITIAL", "FINAL", "ALL"]).withDefault("ALL"),
- responseStatus: parseAsStringEnum(["NOT_RESPONDED", "RESPONDED", "REVISION_REQUESTED", "WAIVED", "ALL"]).withDefault("ALL"),
-
- // 날짜 범위
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetVendorResponsesSchema = Awaited<ReturnType<typeof searchParamsVendorResponseCache.parse>>;
-
-// vendorId + rfqRecordId로 그룹핑된 응답 요약 타입
-export type VendorRfqResponseSummary = {
- id: string; // vendorId + rfqRecordId + rfqType 조합으로 생성된 고유 ID
- vendorId: number;
- rfqRecordId: number;
- rfqType: RfqType;
-
- // RFQ 정보
- rfq: {
- id: number;
- rfqCode: string | null;
- description: string | null;
- status: string;
- dueDate: Date;
- } | null;
-
- // 벤더 정보
- vendor: {
- id: number;
- vendorCode: string;
- vendorName: string;
- country: string | null;
- businessSize: string | null;
- } | null;
-
- // 응답 통계
- totalAttachments: number;
- respondedCount: number;
- pendingCount: number;
- revisionRequestedCount: number;
- waivedCount: number;
- responseRate: number;
- completionRate: number;
- overallStatus: ResponseStatus; // 전체적인 상태
-
- // 날짜 정보
- requestedAt: Date;
- lastRespondedAt: Date | null;
-
- // 기타
- hasComments: boolean;
-};
-
-
-// 수정 요청 스키마
-export const requestRevisionSchema = z.object({
- responseId: z.number().positive(),
- revisionReason: z.string().min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요").max(500),
-});
-
-// 수정 요청 결과 타입
-export type RequestRevisionResult = {
- success: boolean;
- message: string;
- error?: string;
-};
-
-export const shortListConfirmSchema = z.object({
- rfqId: z.number(),
- selectedVendorIds: z.array(z.number()).min(1),
- rejectedVendorIds: z.array(z.number()),
-})
-
-export type ShortListConfirmInput = z.infer<typeof shortListConfirmSchema>
-
-
-export const searchParamsFinalRfqDetailCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - initialRfqDetailView 기반
- sort: getSortingStateParser<FinalRfqDetailView>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
-
-});
-
-export type GetFinalRfqDetailSchema = Awaited<ReturnType<typeof searchParamsFinalRfqDetailCache.parse>>;
-
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
deleted file mode 100644
index 0c2c0c62..00000000
--- a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-// components/rfq/comment-edit-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { MessageSquare, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
-
-const commentFormSchema = z.object({
- responseComment: z.string().optional(),
- vendorComment: z.string().optional(),
-});
-
-type CommentFormData = z.infer<typeof commentFormSchema>;
-
-interface CommentEditDialogProps {
- responseId: number;
- currentResponseComment?: string;
- currentVendorComment?: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function CommentEditDialog({
- responseId,
- currentResponseComment,
- currentVendorComment,
- trigger,
- onSuccess,
-}: CommentEditDialogProps) {
- const [open, setOpen] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<CommentFormData>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- responseComment: currentResponseComment || "",
- vendorComment: currentVendorComment || "",
- },
- });
-
- const onSubmit = async (data: CommentFormData) => {
- setIsSaving(true);
-
- try {
- const response = await fetch("/api/vendor-responses/update-comment", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || "코멘트 업데이트 실패");
- }
-
- toast({
- title: "코멘트 업데이트 완료",
- description: "코멘트가 성공적으로 업데이트되었습니다.",
- });
-
- setOpen(false);
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Comment update error:", error);
- toast({
- title: "업데이트 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsSaving(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <MessageSquare className="h-3 w-3 mr-1" />
- 코멘트
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 코멘트 수정
- </DialogTitle>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 응답 코멘트 */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>응답 코멘트</FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답에 대한 설명을 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더 코멘트 */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더 코멘트 (내부용)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isSaving}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSaving}>
- {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx
deleted file mode 100644
index bc27d103..00000000
--- a/lib/b-rfq/vendor-response/response-detail-columns.tsx
+++ /dev/null
@@ -1,653 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ColumnDef } from "@tanstack/react-table"
-import type { Row } from "@tanstack/react-table"
-import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
-import { formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import {
- FileText,
- Upload,
- CheckCircle,
- Clock,
- AlertTriangle,
- FileX,
- Download,
- AlertCircle,
- RefreshCw,
- Calendar,
- MessageSquare,
- GitBranch,
- Ellipsis
-} from "lucide-react"
-import { cn } from "@/lib/utils"
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"
-import { UploadResponseDialog } from "./upload-response-dialog"
-import { CommentEditDialog } from "./comment-edit-dialog"
-import { WaiveResponseDialog } from "./waive-response-dialog"
-import { ResponseDetailSheet } from "./response-detail-sheet"
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: 'upload' | 'waive' | 'edit' | 'detail'
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | null>>
-}
-
-// 파일 다운로드 핸들러
-async function handleFileDownload(
- filePath: string,
- fileName: string,
- type: "client" | "vendor" = "client",
- id?: number
-) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: type,
- });
-
- if (id) {
- if (type === "client") {
- params.append("revisionId", id.toString());
- } else {
- params.append("responseFileId", id.toString());
- }
- }
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 상태별 정보 반환
-function getEffectiveStatusInfo(effectiveStatus: string) {
- switch (effectiveStatus) {
- case "NOT_RESPONDED":
- return {
- icon: Clock,
- label: "미응답",
- variant: "outline" as const,
- color: "text-orange-600"
- };
- case "UP_TO_DATE":
- return {
- icon: CheckCircle,
- label: "최신",
- variant: "default" as const,
- color: "text-green-600"
- };
- case "VERSION_MISMATCH":
- return {
- icon: RefreshCw,
- label: "업데이트 필요",
- variant: "secondary" as const,
- color: "text-blue-600"
- };
- case "REVISION_REQUESTED":
- return {
- icon: AlertTriangle,
- label: "수정요청",
- variant: "secondary" as const,
- color: "text-yellow-600"
- };
- case "WAIVED":
- return {
- icon: FileX,
- label: "포기",
- variant: "outline" as const,
- color: "text-gray-600"
- };
- default:
- return {
- icon: FileText,
- label: effectiveStatus,
- variant: "outline" as const,
- color: "text-gray-600"
- };
- }
-}
-
-// 파일명 컴포넌트
-function AttachmentFileNameCell({ revisions }: {
- revisions: Array<{
- id: number;
- originalFileName: string;
- revisionNo: string;
- isLatest: boolean;
- filePath?: string;
- fileSize: number;
- createdAt: string;
- revisionComment?: string;
- }>
-}) {
- if (!revisions || revisions.length === 0) {
- return <span className="text-muted-foreground">파일 없음</span>;
- }
-
- const latestRevision = revisions.find(r => r.isLatest) || revisions[0];
- const hasMultipleRevisions = revisions.length > 1;
- const canDownload = latestRevision.filePath;
-
- return (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- {canDownload ? (
- <button
- onClick={() => handleFileDownload(
- latestRevision.filePath!,
- latestRevision.originalFileName,
- "client",
- latestRevision.id
- )}
- className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate"
- title={`${latestRevision.originalFileName} - 클릭하여 다운로드`}
- >
- {latestRevision.originalFileName}
- </button>
- ) : (
- <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}>
- {latestRevision.originalFileName}
- </span>
- )}
-
- {canDownload && (
- <Button
- size="sm"
- variant="ghost"
- className="h-6 w-6 p-0"
- onClick={() => handleFileDownload(
- latestRevision.filePath!,
- latestRevision.originalFileName,
- "client",
- latestRevision.id
- )}
- title="파일 다운로드"
- >
- <Download className="h-3 w-3" />
- </Button>
- )}
-
- {hasMultipleRevisions && (
- <Badge variant="outline" className="text-xs">
- v{latestRevision.revisionNo}
- </Badge>
- )}
- </div>
-
- {hasMultipleRevisions && (
- <div className="text-xs text-muted-foreground">
- 총 {revisions.length}개 리비전
- </div>
- )}
- </div>
- );
-}
-
-// 리비전 비교 컴포넌트
-function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) {
- const isUpToDate = response.isVersionMatched;
- const hasResponse = !!response.respondedRevision;
- const versionLag = response.versionLag || 0;
-
- return (
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <span className="text-xs text-muted-foreground">발주처:</span>
- <Badge variant="secondary" className="text-xs font-mono">
- {response.currentRevision}
- </Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="text-xs text-muted-foreground">응답:</span>
- {hasResponse ? (
- <Badge
- variant={isUpToDate ? "default" : "outline"}
- className={cn(
- "text-xs font-mono",
- !isUpToDate && "text-blue-600 border-blue-300"
- )}
- >
- {response.respondedRevision}
- </Badge>
- ) : (
- <span className="text-xs text-muted-foreground">-</span>
- )}
- </div>
- {hasResponse && !isUpToDate && versionLag > 0 && (
- <div className="flex items-center gap-1 text-xs text-blue-600">
- <AlertCircle className="h-3 w-3" />
- <span>{versionLag}버전 차이</span>
- </div>
- )}
- {response.hasMultipleRevisions && (
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
- <GitBranch className="h-3 w-3" />
- <span>다중 리비전</span>
- </div>
- )}
- </div>
- );
-}
-
-// 코멘트 표시 컴포넌트
-function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) {
- const hasResponseComment = !!response.responseComment;
- const hasVendorComment = !!response.vendorComment;
- const hasRevisionRequestComment = !!response.revisionRequestComment;
- const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment);
-
- const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length;
-
- if (commentCount === 0) {
- return <span className="text-xs text-muted-foreground">-</span>;
- }
-
- return (
- <div className="space-y-1">
- {hasResponseComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div>
- <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}>
- {response.responseComment}
- </span>
- </div>
- )}
-
- {hasVendorComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div>
- <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}>
- {response.vendorComment}
- </span>
- </div>
- )}
-
- {hasRevisionRequestComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div>
- <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}>
- {response.revisionRequestComment}
- </span>
- </div>
- )}
-
- {hasClientComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div>
- <span className="text-xs text-orange-600 truncate max-w-32"
- title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}>
- {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}
- </span>
- </div>
- )}
-
- {/* <div className="text-xs text-muted-foreground text-center">
- {commentCount}개
- </div> */}
- </div>
- );
-}
-
-export function getColumns({
- setRowAction,
-}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] {
- return [
- // 시리얼 번호 - 핀고정용 최소 너비
- {
- accessorKey: "serialNo",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="시리얼" />
- ),
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue("serialNo")}</div>
- ),
-
- meta: {
- excelHeader: "시리얼",
- paddingFactor: 0.8
- },
- },
-
- // 분류 - 핀고정용 적절한 너비
- {
- accessorKey: "attachmentType",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="분류" />
- ),
- cell: ({ row }) => (
- <div className="space-y-1">
- <div className="font-medium text-sm">{row.getValue("attachmentType")}</div>
- {row.original.attachmentDescription && (
- <div className="text-xs text-muted-foreground truncate max-w-32"
- title={row.original.attachmentDescription}>
- {row.original.attachmentDescription}
- </div>
- )}
- </div>
- ),
-
- meta: {
- excelHeader: "분류",
- paddingFactor: 1.0
- },
- },
-
- // 파일명 - 가장 긴 텍스트를 위한 여유 공간
- {
- id: "fileName",
- header: "파일명",
- cell: ({ row }) => (
- <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} />
- ),
-
- meta: {
- paddingFactor: 1.5
- },
- },
-
- // 상태 - 뱃지 크기 고려
- {
- accessorKey: "effectiveStatus",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus"));
- const StatusIcon = statusInfo.icon;
-
- return (
- <div className="space-y-1">
- <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit">
- <StatusIcon className="h-3 w-3" />
- <span>{statusInfo.label}</span>
- </Badge>
- {row.original.needsUpdate && (
- <div className="text-xs text-blue-600 flex items-center gap-1">
- <RefreshCw className="h-3 w-3" />
- <span>업데이트 권장</span>
- </div>
- )}
- </div>
- );
- },
-
- meta: {
- excelHeader: "상태",
- paddingFactor: 1.2
- },
- },
-
- // 리비전 현황 - 복합 정보로 넓은 공간 필요
- {
- id: "revisionStatus",
- header: "리비전 현황",
- cell: ({ row }) => <RevisionComparisonCell response={row.original} />,
-
- meta: {
- paddingFactor: 1.3
- },
- },
-
- // 요청일 - 날짜 형식 고정
- {
- accessorKey: "requestedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="요청일" />
- ),
- cell: ({ row }) => (
- <div className="text-sm flex items-center gap-1">
- <Calendar className="h-3 w-3 text-muted-foreground" />
- <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span>
- </div>
- ),
-
- meta: {
- excelHeader: "요청일",
- paddingFactor: 0.9
- },
- },
-
- // 응답일 - 날짜 형식 고정
- {
- accessorKey: "respondedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="응답일" />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- <span className="whitespace-nowrap">
- {row.getValue("respondedAt")
- ? formatDateTime(new Date(row.getValue("respondedAt")))
- : "-"
- }
- </span>
- </div>
- ),
- meta: {
- excelHeader: "응답일",
- paddingFactor: 0.9
- },
- },
-
- // 응답파일 - 작은 공간
- {
- accessorKey: "totalResponseFiles",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="응답파일" />
- ),
- cell: ({ row }) => (
- <div className="text-center">
- <div className="text-sm font-medium">
- {row.getValue("totalResponseFiles")}개
- </div>
- {row.original.latestResponseFileName && (
- <div className="text-xs text-muted-foreground truncate max-w-20"
- title={row.original.latestResponseFileName}>
- {row.original.latestResponseFileName}
- </div>
- )}
- </div>
- ),
- meta: {
- excelHeader: "응답파일",
- paddingFactor: 0.8
- },
- },
-
- // 코멘트 - 가변 텍스트 길이
- {
- id: "comments",
- header: "코멘트",
- cell: ({ row }) => <CommentDisplayCell response={row.original} />,
- // size: 180,
- meta: {
- paddingFactor: 1.4
- },
- },
-
- // 진행도 - 중간 크기
- {
- id: "progress",
- header: "진행도",
- cell: ({ row }) => (
- <div className="space-y-1 text-center">
- {row.original.hasMultipleRevisions && (
- <Badge variant="outline" className="text-xs">
- 다중 리비전
- </Badge>
- )}
- {row.original.versionLag !== undefined && row.original.versionLag > 0 && (
- <div className="text-xs text-blue-600 whitespace-nowrap">
- {row.original.versionLag}버전 차이
- </div>
- )}
- </div>
- ),
- // size: 100,
- meta: {
- paddingFactor: 1.1
- },
- },
-
-{
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const response = row.original;
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- {/* 상태별 주요 액션들 */}
- {response.effectiveStatus === "NOT_RESPONDED" && (
- <>
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <Upload className="size-4 mr-2" />
- 업로드
- </div>
- }
- />
- </DropdownMenuItem>
- <DropdownMenuItem asChild>
- <WaiveResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileX className="size-4 mr-2" />
- 포기
- </div>
- }
- />
- </DropdownMenuItem>
- </>
- )}
-
- {response.effectiveStatus === "REVISION_REQUESTED" && (
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileText className="size-4 mr-2" />
- 수정
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- {response.effectiveStatus === "VERSION_MISMATCH" && (
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <RefreshCw className="size-4 mr-2" />
- 업데이트
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- {/* 구분선 - 주요 액션과 보조 액션 분리 */}
- {(response.effectiveStatus === "NOT_RESPONDED" ||
- response.effectiveStatus === "REVISION_REQUESTED" ||
- response.effectiveStatus === "VERSION_MISMATCH") &&
- response.effectiveStatus !== "WAIVED" && (
- <DropdownMenuSeparator />
- )}
-
- {/* 공통 액션들 */}
- {response.effectiveStatus !== "WAIVED" && (
- <DropdownMenuItem asChild>
- <CommentEditDialog
- responseId={response.responseId}
- currentResponseComment={response.responseComment || ""}
- currentVendorComment={response.vendorComment || ""}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <MessageSquare className="size-4 mr-2" />
- 코멘트 편집
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- <DropdownMenuItem asChild>
- <ResponseDetailSheet
- response={response}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileText className="size-4 mr-2" />
- 상세보기
- </div>
- }
- />
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- )
- },
- size: 40,
-}
-
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx
deleted file mode 100644
index da7f9b01..00000000
--- a/lib/b-rfq/vendor-response/response-detail-sheet.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-// components/rfq/response-detail-sheet.tsx
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-} from "@/components/ui/sheet";
-import {
- FileText,
- Upload,
- Download,
- AlertCircle,
- MessageSquare,
- FileCheck,
- Eye
-} from "lucide-react";
-import { formatDateTime, formatFileSize } from "@/lib/utils";
-import { cn } from "@/lib/utils";
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service";
-
-// 파일 다운로드 핸들러 (API 사용)
-async function handleFileDownload(
- filePath: string,
- fileName: string,
- type: "client" | "vendor" = "client",
- id?: number
-) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: type,
- });
-
- // ID가 있으면 추가
- if (id) {
- if (type === "client") {
- params.append("revisionId", id.toString());
- } else {
- params.append("responseFileId", id.toString());
- }
- }
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- // Blob으로 파일 데이터 받기
- const blob = await response.blob();
-
- // 임시 URL 생성하여 다운로드
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- // 정리
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- console.log("✅ 파일 다운로드 성공:", fileName);
-
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
-
- // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능)
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 효과적인 상태별 아이콘 및 색상
-function getEffectiveStatusInfo(effectiveStatus: string) {
- switch (effectiveStatus) {
- case "NOT_RESPONDED":
- return {
- label: "미응답",
- variant: "outline" as const
- };
- case "UP_TO_DATE":
- return {
- label: "최신",
- variant: "default" as const
- };
- case "VERSION_MISMATCH":
- return {
- label: "업데이트 필요",
- variant: "secondary" as const
- };
- case "REVISION_REQUESTED":
- return {
- label: "수정요청",
- variant: "secondary" as const
- };
- case "WAIVED":
- return {
- label: "포기",
- variant: "outline" as const
- };
- default:
- return {
- label: effectiveStatus,
- variant: "outline" as const
- };
- }
-}
-
-interface ResponseDetailSheetProps {
- response: EnhancedVendorResponse;
- trigger?: React.ReactNode;
-}
-
-export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) {
- const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1;
- const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0;
-
- return (
- <Sheet>
- <SheetTrigger asChild>
- {trigger || (
- <Button size="sm" variant="ghost">
- <Eye className="h-3 w-3 mr-1" />
- 상세
- </Button>
- )}
- </SheetTrigger>
- <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto">
- <SheetHeader>
- <SheetTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 상세 정보 - {response.serialNo}
- </SheetTitle>
- <SheetDescription>
- {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName}
- </SheetDescription>
- </SheetHeader>
-
- <div className="space-y-6 mt-6">
- {/* 기본 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <AlertCircle className="h-4 w-4" />
- 기본 정보
- </h3>
- <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg">
- <div>
- <div className="text-sm text-muted-foreground">상태</div>
- <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">현재 리비전</div>
- <div className="font-medium">{response.currentRevision}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답 리비전</div>
- <div className="font-medium">{response.respondedRevision || "-"}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답일</div>
- <div className="font-medium">
- {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"}
- </div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">요청일</div>
- <div className="font-medium">
- {formatDateTime(new Date(response.requestedAt))}
- </div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답 파일 수</div>
- <div className="font-medium">{response.totalResponseFiles}개</div>
- </div>
- </div>
- </div>
-
- {/* 코멘트 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <MessageSquare className="h-4 w-4" />
- 코멘트
- </h3>
- <div className="space-y-3">
- {response.responseComment && (
- <div className="p-3 border-l-4 border-blue-500 bg-blue-50">
- <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div>
- <div className="text-sm">{response.responseComment}</div>
- </div>
- )}
- {response.vendorComment && (
- <div className="p-3 border-l-4 border-green-500 bg-green-50">
- <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div>
- <div className="text-sm">{response.vendorComment}</div>
- </div>
- )}
- {response.attachment?.revisions?.find(r => r.revisionComment) && (
- <div className="p-3 border-l-4 border-orange-500 bg-orange-50">
- <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div>
- <div className="text-sm">
- {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment}
- </div>
- </div>
- )}
- {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && (
- <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg">
- 코멘트가 없습니다.
- </div>
- )}
- </div>
- </div>
-
- {/* 발주처 리비전 히스토리 */}
- {hasMultipleRevisions && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <FileCheck className="h-4 w-4" />
- 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개)
- </h3>
- <div className="space-y-3">
- {response.attachment!.revisions
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
- .map((revision) => (
- <div
- key={revision.id}
- className={cn(
- "flex items-center justify-between p-4 rounded-lg border",
- revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white"
- )}
- >
- <div className="flex items-center gap-3 flex-1">
- <Badge variant={revision.isLatest ? "default" : "outline"}>
- {revision.revisionNo}
- </Badge>
- <div className="flex-1">
- <div className="font-medium text-sm">{revision.originalFileName}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))}
- </div>
- {revision.revisionComment && (
- <div className="text-xs text-muted-foreground mt-1 italic">
- "{revision.revisionComment}"
- </div>
- )}
- </div>
- </div>
-
- <div className="flex items-center gap-2">
- {revision.isLatest && (
- <Badge variant="secondary" className="text-xs">최신</Badge>
- )}
- {revision.revisionNo === response.respondedRevision && (
- <Badge variant="outline" className="text-xs text-green-600 border-green-300">
- 응답됨
- </Badge>
- )}
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- if (revision.filePath) {
- handleFileDownload(
- revision.filePath,
- revision.originalFileName,
- "client",
- revision.id
- );
- }
- }}
- disabled={!revision.filePath}
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 벤더 응답 파일들 */}
- {hasResponseFiles && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <Upload className="h-4 w-4" />
- 벤더 응답 파일들 ({response.totalResponseFiles}개)
- </h3>
- <div className="space-y-3">
- {response.responseAttachments!
- .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
- .map((file) => (
- <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200">
- <div className="flex items-center gap-3 flex-1">
- <Badge variant="outline" className="bg-green-100">
- 파일 #{file.fileSequence}
- </Badge>
- <div className="flex-1">
- <div className="font-medium text-sm">{file.originalFileName}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))}
- </div>
- {file.description && (
- <div className="text-xs text-muted-foreground mt-1 italic">
- "{file.description}"
- </div>
- )}
- </div>
- </div>
-
- <div className="flex items-center gap-2">
- {file.isLatestResponseFile && (
- <Badge variant="secondary" className="text-xs">최신</Badge>
- )}
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- if (file.filePath) {
- handleFileDownload(
- file.filePath,
- file.originalFileName,
- "vendor",
- file.id
- );
- }
- }}
- disabled={!file.filePath}
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {!hasMultipleRevisions && !hasResponseFiles && (
- <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg">
- <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
- <p>추가 파일이나 리비전 정보가 없습니다.</p>
- </div>
- )}
- </div>
- </SheetContent>
- </Sheet>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx
deleted file mode 100644
index 124d5241..00000000
--- a/lib/b-rfq/vendor-response/response-detail-table.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"
-import { DataTableAdvancedFilterField } from "@/types/table"
-import { DataTableRowAction, getColumns } from "./response-detail-columns"
-
-interface FinalRfqResponseTableProps {
- data: EnhancedVendorResponse[]
- // ✅ 헤더 정보를 props로 받기
- statistics?: {
- total: number
- upToDate: number
- versionMismatch: number
- pending: number
- revisionRequested: number
- waived: number
- }
- showHeader?: boolean
- title?: string
-}
-
-/**
- * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표
- */
-export function FinalRfqResponseTable({
- data,
- statistics,
- showHeader = true,
- title = "첨부파일별 응답 현황"
-}: FinalRfqResponseTableProps) {
- const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null)
-
- const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [
- {
- id: "effectiveStatus",
- label: "상태",
- type: "select",
- options: [
- { label: "미응답", value: "NOT_RESPONDED" },
- { label: "최신", value: "UP_TO_DATE" },
- { label: "업데이트 필요", value: "VERSION_MISMATCH" },
- { label: "수정요청", value: "REVISION_REQUESTED" },
- { label: "포기", value: "WAIVED" },
- ],
- },
- {
- id: "attachmentType",
- label: "첨부파일 분류",
- type: "text",
- },
- {
- id: "serialNo",
- label: "시리얼 번호",
- type: "text",
- },
- {
- id: "isVersionMatched",
- label: "버전 일치",
- type: "select",
- options: [
- { label: "일치", value: "true" },
- { label: "불일치", value: "false" },
- ],
- },
- {
- id: "hasMultipleRevisions",
- label: "다중 리비전",
- type: "select",
- options: [
- { label: "있음", value: "true" },
- { label: "없음", value: "false" },
- ],
- },
- ]
-
- if (data.length === 0) {
- return (
- <div className="border rounded-lg p-12 text-center">
- <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground">
- 📄
- </div>
- <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p>
- </div>
- )
- }
-
- return (
- // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제
-<>
- {/* 코멘트 범례 */}
- <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg">
- <span className="font-medium">코멘트 범례:</span>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500"></div>
- <span>벤더 응답</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-green-500"></div>
- <span>내부 메모</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500"></div>
- <span>수정 요청</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-orange-500"></div>
- <span>발주처 리비전</span>
- </div>
- </div>
- <div style={{
- width: '100%',
- maxWidth: '100%',
- overflow: 'hidden',
- contain: 'layout'
- }}>
- {/* 데이터 테이블 - 컨테이너 제약 최소화 */}
- <ClientDataTable
- data={data}
- columns={columns}
- advancedFilterFields={advancedFilterFields}
- autoSizeColumns={true}
- compact={true}
- // ✅ RFQ 테이블에 맞는 컬럼 핀고정
- initialColumnPinning={{
- left: ["serialNo", "attachmentType"],
- right: ["actions"]
- }}
- >
- {showHeader && (
- <div className="flex items-center justify-between">
-
- {statistics && (
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>전체 {statistics.total}개</span>
- <span className="text-green-600">최신 {statistics.upToDate}개</span>
- <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span>
- <span className="text-orange-600">미응답 {statistics.pending}개</span>
- {statistics.revisionRequested > 0 && (
- <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span>
- )}
- {statistics.waived > 0 && (
- <span className="text-gray-600">포기 {statistics.waived}개</span>
- )}
- </div>
- )}
- </div>
- )}
- </ClientDataTable>
- </div>
- </>
- )
-}
diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx
deleted file mode 100644
index b4b306d6..00000000
--- a/lib/b-rfq/vendor-response/upload-response-dialog.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-// components/rfq/upload-response-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { Upload, FileText, X, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast"
-import { useRouter } from "next/navigation";
-
-const uploadFormSchema = z.object({
- files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"),
- responseComment: z.string().optional(),
- vendorComment: z.string().optional(),
-});
-
-type UploadFormData = z.infer<typeof uploadFormSchema>;
-
-interface UploadResponseDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- currentRevision: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function UploadResponseDialog({
- responseId,
- attachmentType,
- serialNo,
- currentRevision,
- trigger,
- onSuccess,
-}: UploadResponseDialogProps) {
- const [open, setOpen] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<UploadFormData>({
- resolver: zodResolver(uploadFormSchema),
- defaultValues: {
- files: [],
- responseComment: "",
- vendorComment: "",
- },
- });
-
- const selectedFiles = form.watch("files");
-
- const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
- const files = Array.from(e.target.files || []);
- if (files.length > 0) {
- form.setValue("files", files);
- }
- };
-
- const removeFile = (index: number) => {
- const currentFiles = form.getValues("files");
- const newFiles = currentFiles.filter((_, i) => i !== index);
- form.setValue("files", newFiles);
- };
-
- const formatFileSize = (bytes: number): string => {
- if (bytes === 0) return "0 Bytes";
- const k = 1024;
- const sizes = ["Bytes", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
- };
-
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen);
- // 다이얼로그가 닫힐 때 form 리셋
- if (!newOpen) {
- form.reset();
- }
- };
-
- const handleCancel = () => {
- form.reset();
- setOpen(false);
- };
-
- const onSubmit = async (data: UploadFormData) => {
- setIsUploading(true);
-
- try {
- // 1. 각 파일을 업로드 API로 전송
- const uploadedFiles = [];
-
- for (const file of data.files) {
- const formData = new FormData();
- formData.append("file", file);
- formData.append("responseId", responseId.toString());
- formData.append("description", ""); // 필요시 파일별 설명 추가 가능
-
- const uploadResponse = await fetch("/api/vendor-responses/upload", {
- method: "POST",
- body: formData,
- });
-
- if (!uploadResponse.ok) {
- const error = await uploadResponse.json();
- throw new Error(error.message || "파일 업로드 실패");
- }
-
- const uploadResult = await uploadResponse.json();
- uploadedFiles.push(uploadResult);
- }
-
- // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가)
- const updateResponse = await fetch("/api/vendor-responses/update", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseStatus: "RESPONDED",
- // respondedRevision 제거 - 서버에서 자동 처리
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- respondedAt: new Date().toISOString(),
- }),
- });
-
- if (!updateResponse.ok) {
- const error = await updateResponse.json();
- throw new Error(error.message || "응답 상태 업데이트 실패");
- }
-
- const updateResult = await updateResponse.json();
-
- toast({
- title: "업로드 완료",
- description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`,
- });
-
- setOpen(false);
- form.reset();
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Upload error:", error);
- toast({
- title: "업로드 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsUploading(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm">
- <Upload className="h-3 w-3 mr-1" />
- 업로드
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5" />
- 응답 파일 업로드
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- <Badge variant="secondary">{currentRevision}</Badge>
- <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span>
- </div>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 파일 선택 */}
- <FormField
- control={form.control}
- name="files"
- render={({ field }) => (
- <FormItem>
- <FormLabel>파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-4">
- <Input
- type="file"
- multiple
- onChange={handleFileSelect}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar"
- className="cursor-pointer"
- />
- <div className="text-xs text-muted-foreground">
- 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB)
- </div>
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선택된 파일 목록 */}
- {selectedFiles.length > 0 && (
- <div className="space-y-2">
- <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div>
- <div className="space-y-2 max-h-40 overflow-y-auto">
- {selectedFiles.map((file, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
- >
- <div className="flex items-center gap-2 flex-1 min-w-0">
- <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
- <div className="min-w-0 flex-1">
- <div className="text-sm font-medium truncate">{file.name}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.size)}
- </div>
- </div>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeFile(index)}
- className="flex-shrink-0 ml-2"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 응답 코멘트 */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>응답 코멘트</FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답에 대한 설명을 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더 코멘트 */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더 코멘트 (내부용)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={2}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isUploading || selectedFiles.length === 0}>
- {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isUploading ? "업로드 중..." : "업로드"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
deleted file mode 100644
index 47b7570b..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-// lib/vendor-responses/table/vendor-responses-table-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import {
- Ellipsis, FileText, Pencil, Edit, Trash2,
- Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX
-} from "lucide-react"
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import Link from "next/link"
-import { useRouter } from "next/navigation"
-import { VendorResponseDetail } from "../service"
-import { VendorRfqResponseSummary } from "../validations"
-
-// 응답 상태에 따른 배지 컴포넌트
-function ResponseStatusBadge({ status }: { status: string }) {
- switch (status) {
- case "NOT_RESPONDED":
- return (
- <Badge variant="outline" className="text-orange-600 border-orange-600">
- <Clock className="mr-1 h-3 w-3" />
- 미응답
- </Badge>
- )
- case "RESPONDED":
- return (
- <Badge variant="default" className="bg-green-600 text-white">
- <CheckCircle className="mr-1 h-3 w-3" />
- 응답완료
- </Badge>
- )
- case "REVISION_REQUESTED":
- return (
- <Badge variant="secondary" className="text-yellow-600 border-yellow-600">
- <AlertTriangle className="mr-1 h-3 w-3" />
- 수정요청
- </Badge>
- )
- case "WAIVED":
- return (
- <Badge variant="outline" className="text-gray-600 border-gray-600">
- <FileX className="mr-1 h-3 w-3" />
- 포기
- </Badge>
- )
- default:
- return <Badge>{status}</Badge>
- }
-}
-
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- router: NextRouter
-}
-
-/**
- * tanstack table 컬럼 정의
- */
-export function getColumns({
- router,
-}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] {
-
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorRfqResponseSummary> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (작성하기 버튼만)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<VendorRfqResponseSummary> = {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const vendorId = row.original.vendorId
- const rfqRecordId = row.original.rfqRecordId
- const rfqType = row.original.rfqType
- const rfqCode = row.original.rfq?.rfqCode || "RFQ"
-
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)}
- className="h-8 px-3"
- >
- <Edit className="h-4 w-4 mr-1" />
- 작성하기
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{rfqCode} 응답 작성하기</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- },
- size: 100,
- minSize: 100,
- maxSize: 150,
- }
-
- // ----------------------------------------------------------------
- // 3) 컬럼 정의 배열
- // ----------------------------------------------------------------
- const columnDefinitions = [
- {
- id: "rfqCode",
- label: "RFQ 번호",
- group: "RFQ 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
-
- {
- id: "rfqDueDate",
- label: "RFQ 마감일",
- group: "RFQ 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
-
- {
- id: "overallStatus",
- label: "전체 상태",
- group: null,
- size: 80,
- minSize: 60,
- maxSize: 100,
- },
- {
- id: "totalAttachments",
- label: "총 첨부파일",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "respondedCount",
- label: "응답완료",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "pendingCount",
- label: "미응답",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "responseRate",
- label: "응답률",
- group: "진행률",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "completionRate",
- label: "완료율",
- group: "진행률",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "requestedAt",
- label: "요청일",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- {
- id: "lastRespondedAt",
- label: "최종 응답일",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- ];
-
- // ----------------------------------------------------------------
- // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성)
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {}
-
- columnDefinitions.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // 개별 컬럼 정의
- const columnDef: ColumnDef<VendorRfqResponseSummary> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- cell: ({ row, cell }) => {
- // 각 컬럼별 특별한 렌더링 처리
- switch (cfg.id) {
- case "rfqCode":
- return row.original.rfq?.rfqCode || "-"
-
-
- case "rfqDueDate":
- const dueDate = row.original.rfq?.dueDate;
- return dueDate ? formatDate(new Date(dueDate)) : "-";
-
- case "overallStatus":
- return <ResponseStatusBadge status={row.original.overallStatus} />
-
- case "totalAttachments":
- return (
- <div className="text-center font-medium">
- {row.original.totalAttachments}
- </div>
- )
-
- case "respondedCount":
- return (
- <div className="text-center text-green-600 font-medium">
- {row.original.respondedCount}
- </div>
- )
-
- case "pendingCount":
- return (
- <div className="text-center text-orange-600 font-medium">
- {row.original.pendingCount}
- </div>
- )
-
- case "responseRate":
- const responseRate = row.original.responseRate;
- return (
- <div className="text-center">
- <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
- {responseRate}%
- </span>
- </div>
- )
-
- case "completionRate":
- const completionRate = row.original.completionRate;
- return (
- <div className="text-center">
- <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
- {completionRate}%
- </span>
- </div>
- )
-
- case "requestedAt":
- return formatDateTime(new Date(row.original.requestedAt))
-
- case "lastRespondedAt":
- const lastRespondedAt = row.original.lastRespondedAt;
- return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-";
-
- default:
- return row.getValue(cfg.id) ?? ""
- }
- },
- size: cfg.size,
- minSize: cfg.minSize,
- maxSize: cfg.maxSize,
- }
-
- groupMap[groupName].push(columnDef)
- })
-
- // ----------------------------------------------------------------
- // 5) 그룹별 중첩 컬럼 생성
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹이 없는 컬럼들은 직접 추가
- nestedColumns.push(...colDefs)
- } else {
- // 그룹이 있는 컬럼들은 중첩 구조로 추가
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx
deleted file mode 100644
index 02a5fa59..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-// lib/vendor-responses/table/vendor-responses-table.tsx
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { Button } from "@/components/ui/button"
-import { useRouter } from "next/navigation"
-import { getColumns } from "./vendor-responses-table-columns"
-import { VendorRfqResponseSummary } from "../validations"
-
-interface VendorResponsesTableProps {
- promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>;
-}
-
-export function VendorResponsesTable({ promises }: VendorResponsesTableProps) {
- const [{ data, pageCount, totalCount }] = React.use(promises);
- const router = useRouter();
-
- console.log(data, "vendor responses data")
-
- // 선택된 행 액션 상태
-
- // 테이블 컬럼 정의
- const columns = React.useMemo(() => getColumns({
- router,
- }), [router]);
-
- // 상태별 응답 수 계산 (전체 상태 기준)
- const statusCounts = React.useMemo(() => {
- return {
- NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length,
- RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length,
- REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length,
- WAIVED: data.filter(r => r.overallStatus === "WAIVED").length,
- };
- }, [data]);
-
-
- // 필터 필드
- const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [
- {
- id: "overallStatus",
- label: "전체 상태",
- options: [
- { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED },
- { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED },
- { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED },
- { label: "포기", value: "WAIVED", count: statusCounts.WAIVED },
- ]
- },
-
-
- ];
-
- // 고급 필터 필드
- const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [
-
- {
- id: "overallStatus",
- label: "전체 상태",
- type: "multi-select",
- options: [
- { label: "미응답", value: "NOT_RESPONDED" },
- { label: "응답완료", value: "RESPONDED" },
- { label: "수정요청", value: "REVISION_REQUESTED" },
- { label: "포기", value: "WAIVED" },
- ],
- },
- {
- id: "rfqType",
- label: "RFQ 타입",
- type: "multi-select",
- options: [
- { label: "초기 RFQ", value: "INITIAL" },
- { label: "최종 RFQ", value: "FINAL" },
- ],
- },
- {
- id: "responseRate",
- label: "응답률",
- type: "number",
- },
- {
- id: "completionRate",
- label: "완료율",
- type: "number",
- },
- {
- id: "requestedAt",
- label: "요청일",
- type: "date",
- },
- {
- id: "lastRespondedAt",
- label: "최종 응답일",
- type: "date",
- },
- ];
-
- // useDataTable 훅 사용
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableColumnResizing: true,
- columnResizeMode: 'onChange',
- initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- defaultColumn: {
- minSize: 50,
- maxSize: 500,
- },
- });
-
- return (
- <div className="w-full">
- <div className="flex items-center justify-between py-4">
- <div className="flex items-center space-x-2">
- <span className="text-sm text-muted-foreground">
- 총 {totalCount}개의 응답 요청
- </span>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */}
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx
deleted file mode 100644
index 5ded4da3..00000000
--- a/lib/b-rfq/vendor-response/waive-response-dialog.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-// components/rfq/waive-response-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { FileX, Loader2, AlertTriangle } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
-
-const waiveFormSchema = z.object({
- responseComment: z.string().min(1, "포기 사유를 입력해주세요"),
- vendorComment: z.string().optional(),
-});
-
-type WaiveFormData = z.infer<typeof waiveFormSchema>;
-
-interface WaiveResponseDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function WaiveResponseDialog({
- responseId,
- attachmentType,
- serialNo,
- trigger,
- onSuccess,
-}: WaiveResponseDialogProps) {
- const [open, setOpen] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<WaiveFormData>({
- resolver: zodResolver(waiveFormSchema),
- defaultValues: {
- responseComment: "",
- vendorComment: "",
- },
- });
-
- const onSubmit = async (data: WaiveFormData) => {
- setIsSubmitting(true);
-
- try {
- const response = await fetch("/api/vendor-responses/waive", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || "응답 포기 처리 실패");
- }
-
- toast({
- title: "응답 포기 완료",
- description: "해당 항목에 대한 응답이 포기 처리되었습니다.",
- });
-
- setOpen(false);
- form.reset();
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Waive error:", error);
- toast({
- title: "처리 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <FileX className="h-3 w-3 mr-1" />
- 포기
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2 text-orange-600">
- <FileX className="h-5 w-5" />
- 응답 포기
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- </div>
- </DialogHeader>
-
- <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
- <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2">
- <AlertTriangle className="h-4 w-4" />
- 주의사항
- </div>
- <p className="text-orange-700 text-sm">
- 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다.
- 포기 사유를 명확히 기입해 주세요.
- </p>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 포기 사유 (필수) */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-red-600">
- 포기 사유 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..."
- className="resize-none"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 내부 코멘트 (선택) */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내부 코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={2}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- variant="destructive"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isSubmitting ? "처리 중..." : "포기하기"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/cbe/table/cbe-table-columns.tsx b/lib/cbe/table/cbe-table-columns.tsx
deleted file mode 100644
index 552a0249..00000000
--- a/lib/cbe/table/cbe-table-columns.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Ellipsis, MessageSquare } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
-
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
- >
- router: NextRouter
- openCommentSheet: (responseId: number) => void
- openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처
-
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- router,
- openCommentSheet,
- openVendorContactsDialog
-}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorWithCbeFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
-
- vendorCbeColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<VendorWithTbeFields>
- const childCol: ColumnDef<VendorWithCbeFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
- if (cfg.id === "vendorName") {
- const vendor = row.original;
- const vendorId = vendor.vendorId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (vendorId) {
- openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
-
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "responseStatus") {
- const statusVal = row.original.responseStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
- // 예) CBE Updated (날짜)
- if (cfg.id === "respondedAt") {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal, "KR")
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
-// 댓글 칼럼
-const commentsColumn: ColumnDef<VendorWithCbeFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // setRowAction() 로 type 설정
- setRowAction({ row, type: "comments" })
- // 필요하면 즉시 openCommentSheet() 직접 호출
- openCommentSheet(vendor.responseId ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- minSize: 80,
-}
-// ----------------------------------------------------------------
-// 5) 최종 컬럼 배열 - Update to include the files column
-// ----------------------------------------------------------------
-return [
- selectColumn,
- ...nestedColumns,
- commentsColumn,
- // actionsColumn,
-]
-
-} \ No newline at end of file
diff --git a/lib/cbe/table/cbe-table-toolbar-actions.tsx b/lib/cbe/table/cbe-table-toolbar-actions.tsx
deleted file mode 100644
index 34b5b46c..00000000
--- a/lib/cbe/table/cbe-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-
-
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<VendorWithCbeFields>
- rfqId: number
-}
-
-export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일이 선택되었을 때 처리
-
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
- }
-
- const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0
- ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))]
- : [];
-
-const hasMultipleRfqIds = uniqueRfqIds.length > 1;
-
-const invitationPossibeVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original)
- .filter(vendor => vendor.commercialResponseStatus === null);
-}, [table.getFilteredSelectedRowModel().rows]);
-
-return (
- <div className="flex items-center gap-2">
- {invitationPossibeVendors.length > 0 && (
- <InviteVendorsDialog
- vendors={invitationPossibeVendors}
- rfqId={rfqId}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- hasMultipleRfqIds={hasMultipleRfqIds}
- />
- )}
-
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/cbe/table/cbe-table.tsx b/lib/cbe/table/cbe-table.tsx
deleted file mode 100644
index 38a0a039..00000000
--- a/lib/cbe/table/cbe-table.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getColumns } from "./cbe-table-columns"
-import { CommentSheet, CbeComment } from "./comments-sheet"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { fetchRfqAttachmentsbyCommentId, getAllCBE } from "@/lib/rfqs/service"
-import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { VendorContactsDialog } from "@/lib/rfqs/cbe-table/vendor-contact-dialog"
-import { useSession } from "next-auth/react" // Next-auth session hook 추가
-
-
-
-import { toast } from "sonner"
-
-interface VendorsTableProps {
- promises: Promise<[
- Awaited<ReturnType<typeof getAllCBE>>,
- ]>
-}
-
-export function AllCbeTable({ promises }: VendorsTableProps) {
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- const { data: session } = useSession() // 세션 정보 가져오기
-
- const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
- const currentUser = session?.user
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
- // **router** 획득
- const router = useRouter()
- // 댓글 시트 관련 state
- const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
- const [isLoadingComments, setIsLoadingComments] = React.useState(false)
-
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
- const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
- const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
- const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null)
- const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
-
- // -----------------------------------------------------------
- // 특정 action이 설정될 때마다 실행되는 effect
- // -----------------------------------------------------------
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.responseId))
- }
- }, [rowAction])
-
- // -----------------------------------------------------------
- // 댓글 시트 열기
- // -----------------------------------------------------------
- async function openCommentSheet(responseId: number) {
- setInitialComments([])
- setIsLoadingComments(true)
- const comments = rowAction?.row.original.comments
- const rfqId = rowAction?.row.original.rfqId
- const vendorId = rowAction?.row.original.vendorId
- try {
- if (comments && comments.length > 0) {
- const commentWithAttachments: CbeComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
-
- return {
- ...c,
- commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
- // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
- setInitialComments(commentWithAttachments)
- }
-
- if(vendorId){ setSelectedVendorId(vendorId)}
- if(rfqId){ setSelectedRfqId(rfqId)}
- setSelectedCbeId(responseId)
- setCommentSheetOpen(true)
- }catch (error) {
- console.error("Error loading comments:", error)
- toast.error("Failed to load comments")
- } finally {
- // End loading regardless of success/failure
- setIsLoadingComments(false)
- }
-}
-
-const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => {
- setSelectedVendorId(vendorId)
- setSelectedVendor(vendor)
- setIsContactDialogOpen(true)
-}
-
- // -----------------------------------------------------------
- // 테이블 컬럼
- // -----------------------------------------------------------
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }),
- [setRowAction, router]
- )
-
- // -----------------------------------------------------------
- // 필터 필드
- // -----------------------------------------------------------
- const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [
- // 예: 표준 필터
- ]
- const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "respondedAt", label: "Updated at", type: "date" },
- ]
-
- // -----------------------------------------------------------
- // 테이블 생성 훅
- // -----------------------------------------------------------
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "respondedAt", desc: true }],
- columnPinning: { right: ["comments"] },
- },
- getRowId: (originalRow) => (`${originalRow.vendorId}${originalRow.rfqId}`),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} rfqId={selectedRfqId ?? 0} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 댓글 시트 */}
- <CommentSheet
- currentUserId={currentUserId}
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- vendorId={selectedVendorId ?? 0}
- rfqId={selectedRfqId ?? 0}
- cbeId={selectedCbeId ?? 0}
- isLoading={isLoadingComments}
- initialComments={initialComments}
- />
-
- <InviteVendorsDialog
- vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
- onOpenChange={() => setRowAction(null)}
- rfqId={selectedRfqId ?? 0}
- open={rowAction?.type === "invite"}
- showTrigger={false}
- currentUser={currentUser}
- />
-
- <VendorContactsDialog
- isOpen={isContactDialogOpen}
- onOpenChange={setIsContactDialogOpen}
- vendorId={selectedVendorId}
- vendor={selectedVendor}
- />
- </>
- )
-} \ No newline at end of file
diff --git a/lib/cbe/table/comments-sheet.tsx b/lib/cbe/table/comments-sheet.tsx
deleted file mode 100644
index b4647e7a..00000000
--- a/lib/cbe/table/comments-sheet.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X ,Loader2} from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Textarea,
-} from "@/components/ui/textarea"
-
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput
-} from "@/components/ui/dropzone"
-
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell
-} from "@/components/ui/table"
-
-// DB 스키마에서 필요한 타입들을 가져온다고 가정
-// (실제 프로젝트에 맞춰 import를 수정하세요.)
-import { formatDate } from "@/lib/utils"
-import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
-
-// 코멘트 + 첨부파일 구조 (단순 예시)
-// 실제 DB 스키마에 맞춰 조정
-export interface CbeComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: CbeComment[]
- currentUserId: number
- rfqId: number
- // tbeId?: number
- cbeId?: number
- vendorId: number
- onCommentsUpdated?: (comments: CbeComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 새 코멘트 작성 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional() // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- // tbeId,
- cbeId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
- const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
-
- // RHF 세팅
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: []
- }
- })
-
- // formFieldArray 예시 (파일 목록)
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles"
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
-
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {/* 첨부파일 표시 */}
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt, "KR"): "-"}</TableCell>
- <TableCell>
- {c.commentedByEmail ?? "-"}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // 2) 새 파일 Drop
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
-
- // 3) 저장(Submit)
- async function onSubmit(data: CommentFormValues) {
-
- if (!rfqId) return
- startTransition(async () => {
- try {
- // console.log("rfqId", rfqId)
- // console.log("vendorId", vendorId)
- // console.log("cbeId", cbeId)
- // console.log("currentUserId", currentUserId)
- const res = await createRfqCommentWithAttachments({
- rfqId: rfqId,
- vendorId: vendorId, // 필요시 세팅
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null, // 필요시 세팅
- cbeId: cbeId,
- files: data.newFiles
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 새 코멘트를 다시 불러오거나,
- // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
- const newComment: CbeComment = {
- id: res.commentId, // 서버에서 반환된 commentId
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments: (data.newFiles?.map((f, idx) => ({
- id: Math.random() * 100000,
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [])
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- // 폼 리셋
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- {/* 기존 코멘트 목록 */}
- <div className="max-h-[300px] overflow-y-auto">
- {renderExistingComments()}
- </div>
-
- {/* 새 코멘트 작성 Form */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter your comment..."
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Dropzone (파일 첨부) */}
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {/* 선택된 파일 목록 */}
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div key={field.id} className="flex items-center justify-between border rounded p-2">
- <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/cbe/table/invite-vendors-dialog.tsx b/lib/cbe/table/invite-vendors-dialog.tsx
deleted file mode 100644
index 38edddc1..00000000
--- a/lib/cbe/table/invite-vendors-dialog.tsx
+++ /dev/null
@@ -1,428 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Send, User } from "lucide-react"
-import { toast } from "sonner"
-import { z } from "zod"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { type Row } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import { createCbeEvaluation } from "@/lib/rfqs/service"
-
-// 컴포넌트 내부에서 사용할 폼 스키마 정의
-const formSchema = z.object({
- paymentTerms: z.string().min(1, "지급 조건을 입력하세요"),
- incoterms: z.string().min(1, "Incoterms를 입력하세요"),
- deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
- notes: z.string().optional(),
-})
-
-type FormValues = z.infer<typeof formSchema>
-
-interface InviteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- vendors: Row<VendorWithCbeFields>["original"][]
- currentUserId?: number
- currentUser?: {
- id: string
- name?: string | null
- email?: string | null
- image?: string | null
- companyId?: number | null
- domain?: string | null
- }
- showTrigger?: boolean
- onSuccess?: () => void
- hasMultipleRfqIds?: boolean
-}
-
-export function InviteVendorsDialog({
- rfqId,
- vendors,
- currentUserId,
- currentUser,
- showTrigger = true,
- onSuccess,
- hasMultipleRfqIds,
- ...props
-}: InviteVendorsDialogProps) {
- const [files, setFiles] = React.useState<FileList | null>(null)
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // 로컬 스키마와 폼 값을 사용하도록 수정
- const form = useForm<FormValues>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- paymentTerms: "",
- incoterms: "",
- deliverySchedule: "",
- notes: "",
- },
- mode: "onChange",
- })
-
- // 폼 상태 감시
- const { formState } = form
- const isValid = formState.isValid &&
- !!form.getValues("paymentTerms") &&
- !!form.getValues("incoterms") &&
- !!form.getValues("deliverySchedule")
-
- // 디버깅용 상태 트래킹
- React.useEffect(() => {
- const subscription = form.watch((value) => {
- // 폼 값이 변경될 때마다 실행되는 콜백
- console.log("Form values changed:", value);
- });
-
- return () => subscription.unsubscribe();
- }, [form]);
-
- async function onSubmit(data: FormValues) {
- try {
- setIsSubmitting(true)
-
- // 기본 FormData 생성
- const formData = new FormData()
-
- // rfqId 추가
- formData.append("rfqId", String(rfqId))
-
- // 폼 데이터 추가
- Object.entries(data).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- formData.append(key, String(value))
- }
- })
-
- // 현재 사용자 ID 추가
- if (currentUserId) {
- formData.append("evaluatedBy", String(currentUserId))
- }
-
- // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회)
- vendors.forEach((vendor) => {
- formData.append("vendorIds[]", String(vendor.vendorId))
- })
-
- // 파일 추가 (있는 경우에만)
- if (files && files.length > 0) {
- for (let i = 0; i < files.length; i++) {
- formData.append("files", files[i])
- }
- }
-
- // 서버 액션 호출
- const response = await createCbeEvaluation(formData)
-
- if (response.error) {
- toast.error(response.error)
- return
- }
-
- // 성공 처리
- toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`)
- form.reset()
- setFiles(null)
- props.onOpenChange?.(false)
- onSuccess?.()
- } catch (error) {
- console.error(error)
- toast.error("CBE 평가 생성 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- form.reset()
- setFiles(null)
- }
- props.onOpenChange?.(nextOpen)
- }
-
- // 필수 필드 라벨에 추가할 요소
- const RequiredLabel = (
- <span className="text-destructive ml-1 font-medium">*</span>
- )
-
- const formContent = (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 선택된 협력업체 정보 표시 */}
- <div className="space-y-2">
- <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
- <ScrollArea className="h-20 border rounded-md p-2">
- <div className="flex flex-wrap gap-2">
- {vendors.map((vendor, index) => (
- <Badge key={index} variant="secondary" className="py-1">
- {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
- </Badge>
- ))}
- </div>
- </ScrollArea>
- <FormDescription>
- 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다.
- </FormDescription>
- </div>
-
- {/* 작성자 정보 (읽기 전용) */}
- {currentUser && (
- <div className="border rounded-md p-3 space-y-2">
- <FormLabel>작성자</FormLabel>
- <div className="flex items-center gap-3">
- {currentUser.image ? (
- <Avatar className="h-8 w-8">
- <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
- <AvatarFallback>
- {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
- </AvatarFallback>
- </Avatar>
- ) : (
- <Avatar className="h-8 w-8">
- <AvatarFallback>
- {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
- </AvatarFallback>
- </Avatar>
- )}
- <div>
- <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
- <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
- </div>
- </div>
- </div>
- )}
-
- {/* 지급 조건 - 필수 필드 */}
- <FormField
- control={form.control}
- name="paymentTerms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 지급 조건{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: Net 30" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Incoterms - 필수 필드 */}
- <FormField
- control={form.control}
- name="incoterms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- Incoterms{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: FOB, CIF" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 배송 일정 - 필수 필드 */}
- <FormField
- control={form.control}
- name="deliverySchedule"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 배송 일정{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Textarea
- {...field}
- placeholder="배송 일정 세부사항을 입력하세요"
- rows={3}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 비고 - 선택적 필드 */}
- <FormField
- control={form.control}
- name="notes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- {...field}
- placeholder="추가 비고 사항을 입력하세요"
- rows={3}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 첨부 (옵션) */}
- <div className="space-y-2">
- <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel>
- <Input
- id="files"
- type="file"
- multiple
- onChange={(e) => setFiles(e.target.files)}
- />
- {files && files.length > 0 && (
- <p className="text-sm text-muted-foreground">
- {files.length}개 파일이 첨부되었습니다
- </p>
- )}
- </div>
-
- {/* 필수 입력 항목 안내 */}
- <div className="text-sm text-muted-foreground">
- <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다.
- </div>
-
- {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */}
- {isDesktop && (
- <DialogFooter className="gap-2 pt-4">
- <DialogClose asChild>
- <Button
- type="button"
- variant="outline"
- >
- 취소
- </Button>
- </DialogClose>
- <Button
- type="submit"
- disabled={isSubmitting || !isValid}
- >
- {isSubmitting && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
- </Button>
- </DialogFooter>
- )}
- </form>
- </Form>
- )
- if (hasMultipleRfqIds) {
- toast.error("동일한 RFQ에 대해 선택해주세요");
- return;
- }
- // Desktop Dialog
- if (isDesktop) {
- return (
- <Dialog {...props} onOpenChange={handleDialogOpenChange}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- CBE 평가 전송 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>CBE 평가 생성 및 전송</DialogTitle>
- <DialogDescription>
- 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
- </DialogDescription>
- </DialogHeader>
-
- {formContent}
- </DialogContent>
- </Dialog>
- )
- }
-
- // Mobile Drawer
- return (
- <Drawer {...props} onOpenChange={handleDialogOpenChange}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- CBE 평가 전송 ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle>
- <DrawerDescription>
- 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="px-4">
- {formContent}
- </div>
-
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- onClick={form.handleSubmit(onSubmit)}
- disabled={isSubmitting || !isValid}
- >
- {isSubmitting && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts
deleted file mode 100644
index bc55a1fc..00000000
--- a/lib/legal-review/service.ts
+++ /dev/null
@@ -1,738 +0,0 @@
-'use server'
-
-import { revalidatePath, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema";
-import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm";
-import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations";
-import { filterColumns } from "@/lib/filter-columns";
-import { saveFile } from "../file-stroage";
-
-interface CreateLegalWorkResult {
- success: boolean;
- data?: {
- id: number;
- message: string;
- };
- error?: string;
-}
-
-
-
-export async function createLegalWork(
- data: CreateLegalWorkData
-): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- // 1. 입력 데이터 검증
- const validatedData = createLegalWorkSchema.parse(data);
-
- // 2. 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName,
- })
- .from(vendors)
- .where(eq(vendors.id, validatedData.vendorId))
- .limit(1);
-
- if (!vendor.length) {
- return {
- success: false,
- error: "선택한 벤더를 찾을 수 없습니다.",
- };
- }
-
- const selectedVendor = vendor[0];
-
- // 3. 트랜잭션으로 데이터 삽입
- const result = await db.transaction(async (tx) => {
- // 3-1. legal_works 테이블에 메인 데이터 삽입
- const [legalWorkResult] = await tx
- .insert(legalWorks)
- .values({
- category: validatedData.category,
- status: "신규등록", // 초기 상태
- vendorId: validatedData.vendorId,
- vendorCode: selectedVendor.vendorCode,
- vendorName: selectedVendor.vendorName,
- isUrgent: validatedData.isUrgent,
- requestDate: validatedData.requestDate,
- consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜
- hasAttachment: false, // 초기값
- reviewer: validatedData.reviewer, // 추후 할당
- legalResponder: null, // 추후 할당
- })
- .returning({ id: legalWorks.id });
-
- const legalWorkId = legalWorkResult.id;
-
-
-
- return { legalWorkId };
- });
-
- // 4. 캐시 재검증
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: result.legalWorkId,
- message: "법무업무가 성공적으로 등록되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("createLegalWork 오류:", error);
-
- // 데이터베이스 오류 처리
- if (error instanceof Error) {
- // 외래키 제약 조건 오류
- if (error.message.includes('foreign key constraint')) {
- return {
- success: false,
- error: "선택한 벤더가 유효하지 않습니다.",
- };
- }
-
- // 중복 키 오류 등 기타 DB 오류
- return {
- success: false,
- error: "데이터베이스 오류가 발생했습니다.",
- };
- }
-
- return {
- success: false,
- error: "알 수 없는 오류가 발생했습니다.",
- };
- }
-}
-
-// 법무업무 상태 업데이트 함수 (보너스)
-export async function updateLegalWorkStatus(
- legalWorkId: number,
- status: string,
- reviewer?: string,
- legalResponder?: string
-): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- const updateData: Partial<typeof legalWorks.$inferInsert> = {
- status,
- updatedAt: new Date(),
- };
-
- if (reviewer) updateData.reviewer = reviewer;
- if (legalResponder) updateData.legalResponder = legalResponder;
-
- await db
- .update(legalWorks)
- .set(updateData)
- .where(eq(legalWorks.id, legalWorkId));
-
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: legalWorkId,
- message: "상태가 성공적으로 업데이트되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("updateLegalWorkStatus 오류:", error);
- return {
- success: false,
- error: "상태 업데이트 중 오류가 발생했습니다.",
- };
- }
-}
-
-// 법무업무 삭제 함수 (보너스)
-export async function deleteLegalWork(legalWorkId: number): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- await db.transaction(async (tx) => {
- // 관련 요청 데이터 먼저 삭제
- await tx
- .delete(legalWorkRequests)
- .where(eq(legalWorkRequests.legalWorkId, legalWorkId));
-
- // 메인 법무업무 데이터 삭제
- await tx
- .delete(legalWorks)
- .where(eq(legalWorks.id, legalWorkId));
- });
-
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: legalWorkId,
- message: "법무업무가 성공적으로 삭제되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("deleteLegalWork 오류:", error);
- return {
- success: false,
- error: "삭제 중 오류가 발생했습니다.",
- };
- }
-}
-
-
-export async function getLegalWorks(input: GetLegalWorksSchema) {
- unstable_noStore(); // ✅ 1. 캐싱 방지 추가
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일)
- let advancedWhere: SQL<unknown> | undefined = undefined;
-
- if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
- console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
-
- try {
- advancedWhere = filterColumns({
- table: legalWorksDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
-
- console.log("필터 조건 생성 완료");
- } catch (error) {
- console.error("필터 조건 생성 오류:", error);
- // ✅ 필터 오류 시에도 전체 데이터 반환
- advancedWhere = undefined;
- }
- }
-
- // ✅ 3. 안전한 글로벌 검색 처리
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const searchTerm = `%${input.search}%`;
-
- const searchConditions: SQL<unknown>[] = [
- ilike(legalWorksDetailView.vendorCode, searchTerm),
- ilike(legalWorksDetailView.vendorName, searchTerm),
- ilike(legalWorksDetailView.title, searchTerm),
- ilike(legalWorksDetailView.requestContent, searchTerm),
- ilike(legalWorksDetailView.reviewer, searchTerm),
- ilike(legalWorksDetailView.legalResponder, searchTerm)
- ].filter(Boolean);
-
- if (searchConditions.length > 0) {
- globalWhere = or(...searchConditions);
- }
- }
-
- // ✅ 4. 안전한 WHERE 조건 결합
- const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // ✅ 5. 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(legalWorksDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log("총 데이터 수:", total);
-
- // ✅ 6. 정렬 및 페이징 처리
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect;
- return sort.desc
- ? desc(legalWorksDetailView[column])
- : asc(legalWorksDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(legalWorksDetailView.createdAt));
- }
-
- const legalWorksData = await db
- .select()
- .from(legalWorksDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- console.log("반환 데이터 수:", legalWorksData.length);
-
- return { data: legalWorksData, pageCount, total };
- } catch (err) {
- console.error("getLegalWorks 오류:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-}
-// 특정 법무업무 상세 조회
-export async function getLegalWorkById(id: number) {
- unstable_noStore();
-
- try {
- const result = await db
- .select()
- .from(legalWorksDetailView)
- .where(eq(legalWorksDetailView.id , id))
- .limit(1);
-
- return result[0] || null;
- } catch (error) {
- console.error("getLegalWorkById 오류:", error);
- return null;
- }
-}
-
-// 법무업무 통계 (뷰 테이블 사용)
-export async function getLegalWorksStats() {
- unstable_noStore();
- try {
- // 전체 통계
- const totalStats = await db
- .select({
- total: count(),
- category: legalWorksDetailView.category,
- status: legalWorksDetailView.status,
- isUrgent: legalWorksDetailView.isUrgent,
- })
- .from(legalWorksDetailView);
-
- // 통계 데이터 가공
- const stats = {
- total: totalStats.length,
- byCategory: {} as Record<string, number>,
- byStatus: {} as Record<string, number>,
- urgent: 0,
- };
-
- totalStats.forEach(stat => {
- // 카테고리별 집계
- if (stat.category) {
- stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1;
- }
-
- // 상태별 집계
- if (stat.status) {
- stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1;
- }
-
- // 긴급 건수
- if (stat.isUrgent) {
- stats.urgent++;
- }
- });
-
- return stats;
- } catch (error) {
- console.error("getLegalWorksStatsSimple 오류:", error);
- return {
- total: 0,
- byCategory: {},
- byStatus: {},
- urgent: 0,
- };
- }
-}
-
-// 검토요청 폼 데이터 타입
-interface RequestReviewData {
- // 기본 설정
- dueDate: string
- assignee?: string
- notificationMethod: "email" | "internal" | "both"
-
- // 법무업무 상세 정보
- reviewDepartment: "준법문의" | "법무검토"
- inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문"
-
- // 공통 필드
- title: string
- requestContent: string
-
- // 준법문의 전용 필드
- isPublic?: boolean
-
- // 법무검토 전용 필드들
- contractProjectName?: string
- contractType?: string
- contractCounterparty?: string
- counterpartyType?: "법인" | "개인"
- contractPeriod?: string
- contractAmount?: string
- factualRelation?: string
- projectNumber?: string
- shipownerOrderer?: string
- projectType?: string
- governingLaw?: string
-}
-
-// 첨부파일 업로드 함수
-async function uploadAttachment(file: File, legalWorkId: number, userId?: string) {
- try {
- console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`)
-
- const result = await saveFile({
- file,
- directory: "legal-works",
- originalName: file.name,
- userId: userId || "system"
- })
-
- if (!result.success) {
- throw new Error(result.error || "파일 업로드 실패")
- }
-
- console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`)
-
- return {
- fileName: result.fileName!,
- originalFileName: result.originalName!,
- filePath: result.publicPath!,
- fileSize: result.fileSize!,
- mimeType: file.type,
- securityChecks: result.securityChecks
- }
- } catch (error) {
- console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error)
- throw error
- }
-}
-
-
-export async function requestReview(
- legalWorkId: number,
- formData: RequestReviewData,
- attachments: File[] = [],
- userId?: string
-) {
- try {
- console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`)
-
- // 트랜잭션 시작
- const result = await db.transaction(async (tx) => {
- // 1. legal_works 테이블 업데이트
- const [updatedWork] = await tx
- .update(legalWorks)
- .set({
- status: "검토요청",
- expectedAnswerDate: formData.dueDate,
- hasAttachment: attachments.length > 0,
- updatedAt: new Date(),
- })
- .where(eq(legalWorks.id, legalWorkId))
- .returning()
-
- if (!updatedWork) {
- throw new Error("법무업무를 찾을 수 없습니다.")
- }
-
- console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`)
-
- // 2. legal_work_requests 테이블에 데이터 삽입
- const [createdRequest] = await tx
- .insert(legalWorkRequests)
- .values({
- legalWorkId: legalWorkId,
- reviewDepartment: formData.reviewDepartment,
- inquiryType: formData.inquiryType || null,
- title: formData.title,
- requestContent: formData.requestContent,
-
- // 준법문의 관련 필드
- isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null,
-
- // 법무검토 관련 필드들
- contractProjectName: formData.contractProjectName || null,
- contractType: formData.contractType || null,
- contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null,
-
- // 국내계약 전용 필드들
- contractCounterparty: formData.contractCounterparty || null,
- counterpartyType: formData.counterpartyType || null,
- contractPeriod: formData.contractPeriod || null,
-
- // 자문 관련 필드
- factualRelation: formData.factualRelation || null,
-
- // 해외 관련 필드들
- projectNumber: formData.projectNumber || null,
- shipownerOrderer: formData.shipownerOrderer || null,
- governingLaw: formData.governingLaw || null,
- projectType: formData.projectType || null,
- })
- .returning()
-
- console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`)
-
- // 3. 첨부파일 처리
- const uploadedFiles = []
- const failedFiles = []
-
- if (attachments.length > 0) {
- console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`)
-
- for (const file of attachments) {
- try {
- const uploadResult = await uploadAttachment(file, legalWorkId, userId)
-
- // DB에 첨부파일 정보 저장
- const [attachmentRecord] = await tx
- .insert(legalWorkAttachments)
- .values({
- legalWorkId: legalWorkId,
- fileName: uploadResult.fileName,
- originalFileName: uploadResult.originalFileName,
- filePath: uploadResult.filePath,
- fileSize: uploadResult.fileSize,
- mimeType: uploadResult.mimeType,
- attachmentType: 'request',
- isAutoGenerated: false,
- })
- .returning()
-
- uploadedFiles.push({
- id: attachmentRecord.id,
- name: uploadResult.originalFileName,
- size: uploadResult.fileSize,
- securityChecks: uploadResult.securityChecks
- })
-
- } catch (fileError) {
- console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError)
- failedFiles.push({
- name: file.name,
- error: fileError instanceof Error ? fileError.message : "업로드 실패"
- })
- }
- }
-
- console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`)
- }
-
- return {
- updatedWork,
- createdRequest,
- uploadedFiles,
- failedFiles,
- totalFiles: attachments.length,
- }
- })
-
- // 페이지 재검증
- revalidatePath("/legal-works")
-
- // 성공 메시지 구성
- let message = `검토요청이 성공적으로 발송되었습니다.`
-
- if (result.totalFiles > 0) {
- message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개`
- if (result.failedFiles.length > 0) {
- message += `, 실패 ${result.failedFiles.length}개`
- }
- message += `)`
- }
-
- console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`)
-
- return {
- success: true,
- data: {
- message,
- legalWorkId: legalWorkId,
- requestId: result.createdRequest.id,
- uploadedFiles: result.uploadedFiles,
- failedFiles: result.failedFiles,
- }
- }
-
- } catch (error) {
- console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error)
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다."
- }
- }
-}
-
-
-// FormData를 사용하는 버전 (파일 업로드용)
-export async function requestReviewWithFiles(formData: FormData) {
- try {
- // 기본 데이터 추출
- const legalWorkId = parseInt(formData.get("legalWorkId") as string)
-
- const requestData: RequestReviewData = {
- dueDate: formData.get("dueDate") as string,
- assignee: formData.get("assignee") as string || undefined,
- notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both",
- reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토",
- inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined,
- title: formData.get("title") as string,
- requestContent: formData.get("requestContent") as string,
- isPublic: formData.get("isPublic") === "true",
-
- // 법무검토 관련 필드들
- contractProjectName: formData.get("contractProjectName") as string || undefined,
- contractType: formData.get("contractType") as string || undefined,
- contractCounterparty: formData.get("contractCounterparty") as string || undefined,
- counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined,
- contractPeriod: formData.get("contractPeriod") as string || undefined,
- contractAmount: formData.get("contractAmount") as string || undefined,
- factualRelation: formData.get("factualRelation") as string || undefined,
- projectNumber: formData.get("projectNumber") as string || undefined,
- shipownerOrderer: formData.get("shipownerOrderer") as string || undefined,
- projectType: formData.get("projectType") as string || undefined,
- governingLaw: formData.get("governingLaw") as string || undefined,
- }
-
- // 첨부파일 추출
- const attachments: File[] = []
- for (const [key, value] of formData.entries()) {
- if (key.startsWith("attachment_") && value instanceof File && value.size > 0) {
- attachments.push(value)
- }
- }
-
- return await requestReview(legalWorkId, requestData, attachments)
-
- } catch (error) {
- console.error("FormData 처리 중 오류:", error)
- return {
- success: false,
- error: "요청 데이터 처리 중 오류가 발생했습니다."
- }
- }
-}
-
-// 검토요청 가능 여부 확인
-export async function canRequestReview(legalWorkId: number) {
- try {
- const [work] = await db
- .select({ status: legalWorks.status })
- .from(legalWorks)
- .where(eq(legalWorks.id, legalWorkId))
- .limit(1)
-
- if (!work) {
- return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." }
- }
-
- if (work.status !== "신규등록") {
- return {
- canRequest: false,
- reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.`
- }
- }
-
- return { canRequest: true }
-
- } catch (error) {
- console.error("검토요청 가능 여부 확인 중 오류:", error)
- return {
- canRequest: false,
- reason: "상태 확인 중 오류가 발생했습니다."
- }
- }
-}
-
-// 삭제 요청 타입
-interface RemoveLegalWorksInput {
- ids: number[]
-}
-
-// 응답 타입
-interface RemoveLegalWorksResponse {
- error?: string
- success?: boolean
-}
-
-/**
- * 법무업무 삭제 서버 액션
- */
-export async function removeLegalWorks({
- ids,
-}: RemoveLegalWorksInput): Promise<RemoveLegalWorksResponse> {
- try {
- // 유효성 검사
- if (!ids || ids.length === 0) {
- return {
- error: "삭제할 법무업무를 선택해주세요.",
- }
- }
-
- // 삭제 가능한 상태인지 확인 (선택적)
- const existingWorks = await db
- .select({ id: legalWorks.id, status: legalWorks.status })
- .from(legalWorks)
- .where(inArray(legalWorks.id, ids))
-
- // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가)
- const nonDeletableWorks = existingWorks.filter(
- work => work.status === "검토중" || work.status === "담당자배정"
- )
-
- if (nonDeletableWorks.length > 0) {
- return {
- error: "진행중인 법무업무는 삭제할 수 없습니다.",
- }
- }
-
- // 실제 삭제 실행
- const result = await db
- .delete(legalWorks)
- .where(inArray(legalWorks.id, ids))
-
- // 결과 확인
- if (result.changes === 0) {
- return {
- error: "삭제할 법무업무를 찾을 수 없습니다.",
- }
- }
-
- // 캐시 재검증
- revalidatePath("/legal-works") // 실제 경로에 맞게 수정
-
- return {
- success: true,
- }
-
- } catch (error) {
- console.error("법무업무 삭제 중 오류 발생:", error)
-
- return {
- error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.",
- }
- }
-}
-
-/**
- * 단일 법무업무 삭제 (선택적)
- */
-export async function removeLegalWork(id: number): Promise<RemoveLegalWorksResponse> {
- return removeLegalWorks({ ids: [id] })
-} \ No newline at end of file
diff --git a/lib/legal-review/status/create-legal-work-dialog.tsx b/lib/legal-review/status/create-legal-work-dialog.tsx
deleted file mode 100644
index 0ee1c430..00000000
--- a/lib/legal-review/status/create-legal-work-dialog.tsx
+++ /dev/null
@@ -1,506 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { Loader2, Check, ChevronsUpDown, Calendar, User } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Switch } from "@/components/ui/switch"
-import { cn } from "@/lib/utils"
-import { getVendorsForSelection } from "@/lib/b-rfq/service"
-import { createLegalWork } from "../service"
-import { useSession } from "next-auth/react"
-
-interface CreateLegalWorkDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess?: () => void
- onDataChange?: () => void
-}
-
-// legalWorks 테이블에 맞춘 단순화된 폼 스키마
-const createLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
- expectedAnswerDate: z.string().optional(),
- reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
-})
-
-type CreateLegalWorkFormValues = z.infer<typeof createLegalWorkSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-export function CreateLegalWorkDialog({
- open,
- onOpenChange,
- onSuccess,
- onDataChange
-}: CreateLegalWorkDialogProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorOpen, setVendorOpen] = React.useState(false)
- const { data: session } = useSession()
-
- const userName = React.useMemo(() => {
- return session?.user?.name || "";
- }, [session]);
-
- const userEmail = React.useMemo(() => {
- return session?.user?.email || "";
- }, [session]);
-
- const defaultReviewer = React.useMemo(() => {
- if (userName && userEmail) {
- return `${userName} (${userEmail})`;
- } else if (userName) {
- return userName;
- } else if (userEmail) {
- return userEmail;
- }
- return "";
- }, [userName, userEmail]);
-
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- // 오늘 날짜 + 7일 후를 기본 답변요청일로 설정
- const getDefaultRequestDate = () => {
- const date = new Date()
- date.setDate(date.getDate() + 7)
- return date.toISOString().split('T')[0]
- }
-
- // 답변요청일 + 3일 후를 기본 답변예정일로 설정
- const getDefaultExpectedDate = (requestDate: string) => {
- if (!requestDate) return ""
- const date = new Date(requestDate)
- date.setDate(date.getDate() + 3)
- return date.toISOString().split('T')[0]
- }
-
- const form = useForm<CreateLegalWorkFormValues>({
- resolver: zodResolver(createLegalWorkSchema),
- defaultValues: {
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- },
- })
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 세션 정보가 로드되면 검토요청자 필드 업데이트
- React.useEffect(() => {
- if (defaultReviewer) {
- form.setValue("reviewer", defaultReviewer)
- }
- }, [defaultReviewer, form])
-
- // 답변요청일 변경시 답변예정일 자동 설정
- const requestDate = form.watch("requestDate")
- React.useEffect(() => {
- if (requestDate) {
- const expectedDate = getDefaultExpectedDate(requestDate)
- form.setValue("expectedAnswerDate", expectedDate)
- }
- }, [requestDate, form])
-
- // 폼 제출 - 서버 액션 적용
- async function onSubmit(data: CreateLegalWorkFormValues) {
- console.log("Form submitted with data:", data)
- setIsSubmitting(true)
-
- try {
- // legalWorks 테이블에 맞춘 데이터 구조
- const legalWorkData = {
- ...data,
- // status는 서버에서 "검토요청"으로 설정
- // consultationDate는 서버에서 오늘 날짜로 설정
- // hasAttachment는 서버에서 false로 설정
- }
-
- const result = await createLegalWork(legalWorkData)
-
- if (result.success) {
- toast.success(result.data?.message || "법무업무가 성공적으로 등록되었습니다.")
- onOpenChange(false)
- form.reset({
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- })
- onSuccess?.()
- onDataChange?.()
- router.refresh()
- } else {
- toast.error(result.error || "등록 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error creating legal work:", error)
- toast.error("등록 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- form.reset({
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- })
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-2xl h-[80vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>법무업무 신규 등록</DialogTitle>
- <DialogDescription>
- 새로운 법무업무를 등록합니다. 상세한 검토 요청은 등록 후 별도로 진행할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto p-6">
- <div className="space-y-6">
- {/* 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-2 gap-4">
- {/* 구분 */}
- <FormField
- control={form.control}
- name="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="CP">CP</SelectItem>
- <SelectItem value="GTC">GTC</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">긴급 요청</FormLabel>
- <div className="text-sm text-muted-foreground">
- 긴급 처리가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </div>
-
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더</FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- >
- {selectedVendor ? (
- <span className="flex items-center gap-2">
- <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
- {selectedVendor.vendorName}
- </span>
- ) : (
- "벤더 선택..."
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput placeholder="벤더 검색..." />
- <CommandList
- onWheel={(e) => {
- e.stopPropagation(); // 이벤트 전파 차단
- const target = e.currentTarget;
- target.scrollTop += e.deltaY; // 직접 스크롤 처리
- }}>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- <div className="flex items-center gap-2">
- <Badge variant="outline">{vendor.vendorCode}</Badge>
- <span>{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 담당자 및 일정 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <Calendar className="h-5 w-5" />
- 담당자 및 일정
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토요청자 */}
- <FormField
- control={form.control}
- name="reviewer"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-2">
- <User className="h-4 w-4" />
- 검토요청자
- </FormLabel>
- <FormControl>
- <Input
- placeholder={defaultReviewer || "검토요청자 이름을 입력하세요"}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- {/* 답변요청일 */}
- <FormField
- control={form.control}
- name="requestDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변요청일</FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 답변예정일 */}
- <FormField
- control={form.control}
- name="expectedAnswerDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변예정일 (선택사항)</FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <div className="text-xs text-muted-foreground">
- 답변요청일 기준으로 자동 설정됩니다
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- {/* 안내 메시지 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardContent className="pt-6">
- <div className="flex items-start gap-3">
- <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
- <div className="space-y-1">
- <p className="text-sm font-medium text-blue-900">
- 법무업무 등록 안내
- </p>
- <p className="text-sm text-blue-700">
- 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다.
- </p>
- <p className="text-xs text-blue-600">
- • 상태: "검토요청"으로 자동 설정<br/>
- • 의뢰일: 오늘 날짜로 자동 설정<br/>
- • 법무답변자: 나중에 배정
- </p>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-end gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 등록
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/delete-legal-works-dialog.tsx b/lib/legal-review/status/delete-legal-works-dialog.tsx
deleted file mode 100644
index 665dafc2..00000000
--- a/lib/legal-review/status/delete-legal-works-dialog.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type LegalWorksDetailView } from "@/db/schema"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { useRouter } from "next/navigation"
-
-import { removeLegalWorks } from "../service"
-
-interface DeleteLegalWorksDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- legalWorks: Row<LegalWorksDetailView>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteLegalWorksDialog({
- legalWorks,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteLegalWorksDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const router = useRouter()
-
- function onDelete() {
- startDeleteTransition(async () => {
- const { error } = await removeLegalWorks({
- ids: legalWorks.map((work) => work.id),
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- router.refresh()
- toast.success("법무업무가 삭제되었습니다")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({legalWorks.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{legalWorks.length}</span>
- 건의 법무업무가 완전히 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected legal works"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({legalWorks.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{legalWorks.length}</span>
- 건의 법무업무가 완전히 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected legal works"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-table copy.tsx b/lib/legal-review/status/legal-table copy.tsx
deleted file mode 100644
index 92abfaf6..00000000
--- a/lib/legal-review/status/legal-table copy.tsx
+++ /dev/null
@@ -1,583 +0,0 @@
-// ============================================================================
-// legal-works-table.tsx - EvaluationTargetsTable을 정확히 복사해서 수정
-// ============================================================================
-"use client";
-
-import * as React from "react";
-import { useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table";
-import { useDataTable } from "@/hooks/use-data-table";
-import { DataTable } from "@/components/data-table/data-table";
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
-import { getLegalWorks } from "../service";
-import { cn } from "@/lib/utils";
-import { useTablePresets } from "@/components/data-table/use-table-presets";
-import { TablePresetManager } from "@/components/data-table/data-table-preset";
-import { getLegalWorksColumns } from "./legal-works-columns";
-import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
-import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
-import { LegalWorksDetailView } from "@/db/schema";
-import { EditLegalWorkSheet } from "./update-legal-work-dialog";
-import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
-
-/* -------------------------------------------------------------------------- */
-/* Stats Card */
-/* -------------------------------------------------------------------------- */
-function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
- const stats = React.useMemo(() => {
- const total = data.length;
- const pending = data.filter(item => item.status === '검토요청').length;
- const assigned = data.filter(item => item.status === '담당자배정').length;
- const inProgress = data.filter(item => item.status === '검토중').length;
- const completed = data.filter(item => item.status === '답변완료').length;
- const urgent = data.filter(item => item.isUrgent).length;
-
- return { total, pending, assigned, inProgress, completed, urgent };
- }, [data]);
-
- if (stats.total === 0) {
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card className="col-span-full">
- <CardContent className="pt-6 text-center text-sm text-muted-foreground">
- 등록된 법무업무가 없습니다.
- </CardContent>
- </Card>
- </div>
- );
- }
-
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">총 건수</CardTitle>
- <Badge variant="outline">전체</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- 긴급 {stats.urgent}건
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">검토요청</CardTitle>
- <Badge variant="secondary">대기</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">담당자배정</CardTitle>
- <Badge variant="secondary">진행</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">검토중</CardTitle>
- <Badge variant="secondary">진행</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">답변완료</CardTitle>
- <Badge variant="default">완료</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
- </div>
- );
-}
-
-/* -------------------------------------------------------------------------- */
-/* LegalWorksTable */
-/* -------------------------------------------------------------------------- */
-interface LegalWorksTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
- currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할
- className?: string;
-}
-
-export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
- const searchParams = useSearchParams();
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태
- const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
- const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 필터 핸들러
- const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
- console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
- setExternalFilters(filters);
- setExternalJoinOperator(joinOperator);
- setIsFilterPanelOpen(false);
- }, []);
-
- const searchString = React.useMemo(
- () => searchParams.toString(),
- [searchParams]
- );
-
- const getSearchParam = React.useCallback(
- (key: string, def = "") =>
- new URLSearchParams(searchString).get(key) ?? def,
- [searchString]
- );
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 URL 필터 변경 감지 및 데이터 새로고침
- React.useEffect(() => {
- const refetchData = async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- // ✅ currentYear 추가 (EvaluationTargetsTable의 evaluationYear와 동일)
- currentYear: currentYear
- };
-
- console.log("=== 새 데이터 요청 ===", searchParams);
-
- // 서버 액션 직접 호출
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- };
-
- // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
- const timeoutId = setTimeout(() => {
- // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
- const hasChanges = getSearchParam("filters") ||
- getSearchParam("search") ||
- getSearchParam("page") !== "1" ||
- getSearchParam("perPage") !== "10" ||
- getSearchParam("sort");
-
- if (hasChanges) {
- refetchData();
- }
- }, 300); // 디바운스 시간 단축
-
- return () => clearTimeout(timeoutId);
- }, [searchString, currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 정확히 동일한 의존성
-
- const refreshData = React.useCallback(async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터로 데이터 새로고침
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- }, [currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 동일한 의존성
-
- /* --------------------------- layout refs --------------------------- */
- const containerRef = React.useRef<HTMLDivElement>(null);
- const [containerTop, setContainerTop] = React.useState(0);
-
- const updateContainerBounds = React.useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- const newTop = rect.top
- setContainerTop(prevTop => {
- if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
- }
- }, [])
-
- React.useEffect(() => {
- updateContainerBounds();
-
- const handleResize = () => {
- updateContainerBounds();
- };
-
- window.addEventListener('resize', handleResize);
- window.addEventListener('scroll', updateContainerBounds);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('scroll', updateContainerBounds);
- };
- }, [updateContainerBounds]);
-
- /* ---------------------- 데이터 상태 관리 ---------------------- */
- // 초기 데이터 설정
- const [initialPromiseData] = React.use(promises);
-
- // ✅ 테이블 데이터 상태 추가
- const [tableData, setTableData] = React.useState(initialPromiseData);
- const [isDataLoading, setIsDataLoading] = React.useState(false);
-
- const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
- try {
- const value = getSearchParam(key);
- return value ? JSON.parse(value) : defaultValue;
- } catch {
- return defaultValue;
- }
- }, [getSearchParam]);
-
- const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
- return parseSearchParamHelper(key, defaultValue);
- };
-
- /* ---------------------- 초기 설정 ---------------------------- */
- const initialSettings = React.useMemo(() => ({
- page: parseInt(getSearchParam("page", "1")),
- perPage: parseInt(getSearchParam("perPage", "10")),
- sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
- filters: parseSearchParam("filters", []),
- joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
- search: getSearchParam("search", ""),
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: [],
- }), [getSearchParam, parseSearchParam]);
-
- /* --------------------- 프리셋 훅 ------------------------------ */
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- getCurrentSettings,
- } = useTablePresets<LegalWorksDetailView>(
- "legal-works-table",
- initialSettings
- );
-
- /* --------------------- 컬럼 ------------------------------ */
- const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
-
- /* 기본 필터 */
- const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
- { id: "vendorCode", label: "벤더 코드" },
- { id: "vendorName", label: "벤더명" },
- { id: "status", label: "상태" },
- ];
-
- /* 고급 필터 */
- const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
- {
- id: "category", label: "구분", type: "select", options: [
- { label: "CP", value: "CP" },
- { label: "GTC", value: "GTC" },
- { label: "기타", value: "기타" }
- ]
- },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "검토요청", value: "검토요청" },
- { label: "담당자배정", value: "담당자배정" },
- { label: "검토중", value: "검토중" },
- { label: "답변완료", value: "답변완료" },
- { label: "재검토요청", value: "재검토요청" },
- { label: "보류", value: "보류" },
- { label: "취소", value: "취소" }
- ]
- },
- { id: "vendorCode", label: "벤더 코드", type: "text" },
- { id: "vendorName", label: "벤더명", type: "text" },
- {
- id: "isUrgent", label: "긴급여부", type: "select", options: [
- { label: "긴급", value: "true" },
- { label: "일반", value: "false" }
- ]
- },
- {
- id: "reviewDepartment", label: "검토부문", type: "select", options: [
- { label: "준법문의", value: "준법문의" },
- { label: "법무검토", value: "법무검토" }
- ]
- },
- {
- id: "inquiryType", label: "문의종류", type: "select", options: [
- { label: "국내계약", value: "국내계약" },
- { label: "국내자문", value: "국내자문" },
- { label: "해외계약", value: "해외계약" },
- { label: "해외자문", value: "해외자문" }
- ]
- },
- { id: "reviewer", label: "검토요청자", type: "text" },
- { id: "legalResponder", label: "법무답변자", type: "text" },
- { id: "requestDate", label: "답변요청일", type: "date" },
- { id: "consultationDate", label: "의뢰일", type: "date" },
- { id: "expectedAnswerDate", label: "답변예정일", type: "date" },
- { id: "legalCompletionDate", label: "법무완료일", type: "date" },
- { id: "createdAt", label: "생성일", type: "date" },
- ];
-
- /* current settings */
- const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
-
- const initialState = React.useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- /* ----------------------- useDataTable ------------------------ */
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (row) => String(row.id),
- shallow: false,
- clearOnDefault: true,
- });
-
- /* ---------------------- helper ------------------------------ */
- const getActiveFilterCount = React.useCallback(() => {
- try {
- // URL에서 현재 필터 수 확인
- const filtersParam = getSearchParam("filters");
- if (filtersParam) {
- const filters = JSON.parse(filtersParam);
- return Array.isArray(filters) ? filters.length : 0;
- }
- return 0;
- } catch {
- return 0;
- }
- }, [getSearchParam]);
-
- const FILTER_PANEL_WIDTH = 400;
-
- /* ---------------------------- JSX ---------------------------- */
- return (
- <>
- {/* Filter Panel */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- <LegalWorkFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onFiltersApply={handleFiltersApply}
- isLoading={false}
- />
- </div>
-
- {/* Main Container */}
- <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}>
- <div className="flex w-full h-full">
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%",
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
- }}
- >
- {/* Header */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveFilterCount()}
- </span>
- )}
- </Button>
- <div className="text-sm text-muted-foreground">
- 총 {tableData.total || tableData.data.length}건
- </div>
- </div>
-
- {/* Stats */}
- <div className="px-4">
- <LegalWorksStats data={tableData.data} />
- </div>
-
- {/* Table */}
- <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
- {isDataLoading && (
- <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
- 필터링 중...
- </div>
- </div>
- )}
- <DataTable table={table} className="h-full">
- {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */}
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- debounceMs={300}
- shallow={false}
- externalFilters={externalFilters}
- externalJoinOperator={externalJoinOperator}
- onFiltersChange={(filters, joinOperator) => {
- console.log("=== 필터 변경 감지 ===", filters, joinOperator);
- }}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<LegalWorksDetailView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <LegalWorksTableToolbarActions table={table} onRefresh={refreshData} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 편집 다이얼로그 */}
- <EditLegalWorkSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- work={rowAction?.row.original ?? null}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false);
- refreshData();
- }}
- />
-
- <LegalWorkDetailDialog
- open={rowAction?.type === "view"}
- onOpenChange={(open) => !open && setRowAction(null)}
- work={rowAction?.row.original || null}
- />
-
- <DeleteLegalWorksDialog
- open={rowAction?.type === "delete"}
- onOpenChange={(open) => !open && setRowAction(null)}
- legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null);
- refreshData();
- }}
- />
- </div>
- </div>
- </div>
- </div>
- </>
- );
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx
deleted file mode 100644
index 4df3568c..00000000
--- a/lib/legal-review/status/legal-table.tsx
+++ /dev/null
@@ -1,546 +0,0 @@
-// ============================================================================
-// components/evaluation-targets-table.tsx (CLIENT COMPONENT)
-// ─ 정리된 버전 ─
-// ============================================================================
-"use client";
-
-import * as React from "react";
-import { useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { HelpCircle, PanelLeftClose, PanelLeftOpen } from "lucide-react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Skeleton } from "@/components/ui/skeleton";
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table";
-import { useDataTable } from "@/hooks/use-data-table";
-import { DataTable } from "@/components/data-table/data-table";
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; // ✅ 확장된 버전 사용
-import { cn } from "@/lib/utils";
-import { useTablePresets } from "@/components/data-table/use-table-presets";
-import { TablePresetManager } from "@/components/data-table/data-table-preset";
-import { LegalWorksDetailView } from "@/db/schema";
-import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
-import { getLegalWorks } from "../service";
-import { getLegalWorksColumns } from "./legal-works-columns";
-import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
-import { EditLegalWorkSheet } from "./update-legal-work-dialog";
-import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
-
-
-/* -------------------------------------------------------------------------- */
-/* Stats Card */
-/* -------------------------------------------------------------------------- */
-function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
- const stats = React.useMemo(() => {
- const total = data.length;
- const pending = data.filter(item => item.status === '검토요청').length;
- const assigned = data.filter(item => item.status === '담당자배정').length;
- const inProgress = data.filter(item => item.status === '검토중').length;
- const completed = data.filter(item => item.status === '답변완료').length;
- const urgent = data.filter(item => item.isUrgent).length;
-
- return { total, pending, assigned, inProgress, completed, urgent };
- }, [data]);
-
- if (stats.total === 0) {
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card className="col-span-full">
- <CardContent className="pt-6 text-center text-sm text-muted-foreground">
- 등록된 법무업무가 없습니다.
- </CardContent>
- </Card>
- </div>
- );
- }
-
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">총 건수</CardTitle>
- <Badge variant="outline">전체</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- 긴급 {stats.urgent}건
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">검토요청</CardTitle>
- <Badge variant="secondary">대기</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">담당자배정</CardTitle>
- <Badge variant="secondary">진행</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">검토중</CardTitle>
- <Badge variant="secondary">진행</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">답변완료</CardTitle>
- <Badge variant="default">완료</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
- </div>
- );
-}
-
-/* -------------------------------------------------------------------------- */
-/* EvaluationTargetsTable */
-/* -------------------------------------------------------------------------- */
-interface LegalWorksTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
- currentYear: number;
- className?: string;
-}
-
-export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
- const searchParams = useSearchParams();
-
- // ✅ 외부 필터 상태 (폼에서 전달받은 필터)
- const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
- const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
-
- // ✅ 폼에서 전달받은 필터를 처리하는 핸들러
- const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
- console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
- setExternalFilters(filters);
- setExternalJoinOperator(joinOperator);
- // 필터 적용 후 패널 닫기
- setIsFilterPanelOpen(false);
- }, []);
-
-
- const searchString = React.useMemo(
- () => searchParams.toString(),
- [searchParams]
- );
-
- const getSearchParam = React.useCallback(
- (key: string, def = "") =>
- new URLSearchParams(searchString).get(key) ?? def,
- [searchString]
- );
-
-
- // ✅ URL 필터 변경 감지 및 데이터 새로고침
- React.useEffect(() => {
- const refetchData = async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- console.log("=== 새 데이터 요청 ===", searchParams);
-
- // 서버 액션 직접 호출
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- };
-
- /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
-
- // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
- const timeoutId = setTimeout(() => {
- // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
- const hasChanges = getSearchParam("filters") ||
- getSearchParam("search") ||
- getSearchParam("page") !== "1" ||
- getSearchParam("perPage") !== "10" ||
- getSearchParam("sort");
-
- if (hasChanges) {
- refetchData();
- }
- }, 300); // 디바운스 시간 단축
-
- return () => clearTimeout(timeoutId);
- }, [searchString, currentYear, getSearchParam]);
-
- const refreshData = React.useCallback(async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터로 데이터 새로고침
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- }, [currentYear, getSearchParam]);
-
- /* --------------------------- layout refs --------------------------- */
- const containerRef = React.useRef<HTMLDivElement>(null);
- const [containerTop, setContainerTop] = React.useState(0);
-
- const updateContainerBounds = React.useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- const newTop = rect.top
- setContainerTop(prevTop => {
- if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
- }
- }, [])
- React.useEffect(() => {
- updateContainerBounds();
-
- const handleResize = () => {
- updateContainerBounds();
- };
-
- window.addEventListener('resize', handleResize);
- window.addEventListener('scroll', updateContainerBounds);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('scroll', updateContainerBounds);
- };
- }, [updateContainerBounds]);
-
- /* ---------------------- 데이터 상태 관리 ---------------------- */
- // 초기 데이터 설정
- const [initialPromiseData] = React.use(promises);
-
- // ✅ 테이블 데이터 상태 추가
- const [tableData, setTableData] = React.useState(initialPromiseData);
- const [isDataLoading, setIsDataLoading] = React.useState(false);
-
- const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
- try {
- const value = getSearchParam(key);
- return value ? JSON.parse(value) : defaultValue;
- } catch {
- return defaultValue;
- }
- }, [getSearchParam]);
-
- const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
- return parseSearchParamHelper(key, defaultValue);
- };
-
- /* ---------------------- 초기 설정 ---------------------------- */
- const initialSettings = React.useMemo(() => ({
- page: parseInt(getSearchParam("page", "1")),
- perPage: parseInt(getSearchParam("perPage", "10")),
- sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
- filters: parseSearchParam("filters", []),
- joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
- search: getSearchParam("search", ""),
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: [],
- }), [getSearchParam, parseSearchParam]);
-
- /* --------------------- 프리셋 훅 ------------------------------ */
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- getCurrentSettings,
- } = useTablePresets<LegalWorksDetailView>(
- "legal-review-table",
- initialSettings
- );
-
-
-
- /* --------------------- 컬럼 ------------------------------ */
- const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
-
- /* 기본 필터 */
- const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
- { id: "vendorCode", label: "벤더 코드" },
- { id: "vendorName", label: "벤더명" },
- { id: "status", label: "상태" },
- ];
-
- /* 고급 필터 */
- const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
- ];
-
- /* current settings */
- const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
-
- const initialState = React.useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- /* ----------------------- useDataTable ------------------------ */
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (row) => String(row.id),
- shallow: false,
- clearOnDefault: true,
- });
-
- /* ---------------------- helper ------------------------------ */
- const getActiveFilterCount = React.useCallback(() => {
- try {
- // URL에서 현재 필터 수 확인
- const filtersParam = getSearchParam("filters");
- if (filtersParam) {
- const filters = JSON.parse(filtersParam);
- return Array.isArray(filters) ? filters.length : 0;
- }
- return 0;
- } catch {
- return 0;
- }
- }, [getSearchParam]);
-
- const FILTER_PANEL_WIDTH = 400;
-
- /* ---------------------------- JSX ---------------------------- */
- return (
- <>
- {/* Filter Panel */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- <LegalWorkFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달
- isLoading={false}
- />
- </div>
-
- {/* Main Container */}
- <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}>
- <div className="flex w-full h-full">
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%",
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
- }}
- >
- {/* Header */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveFilterCount()}
- </span>
- )}
- </Button>
- <div className="text-sm text-muted-foreground">
- 총 {tableData.total || tableData.data.length}건
- </div>
- </div>
-
- {/* Stats */}
- <div className="px-4">
- <LegalWorksStats data={tableData.data} />
-
- </div>
-
- {/* Table */}
- <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
- {isDataLoading && (
- <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
- 필터링 중...
- </div>
- </div>
- )}
- <DataTable table={table} className="h-full">
- {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */}
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- debounceMs={300}
- shallow={false}
- externalFilters={externalFilters}
- externalJoinOperator={externalJoinOperator}
- onFiltersChange={(filters, joinOperator) => {
- console.log("=== 필터 변경 감지 ===", filters, joinOperator);
- }}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<LegalWorksDetailView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 다이얼로그들 */}
- <EditLegalWorkSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- work={rowAction?.row.original || null}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false);
- refreshData();
- }}
- />
-
- <LegalWorkDetailDialog
- open={rowAction?.type === "view"}
- onOpenChange={(open) => !open && setRowAction(null)}
- work={rowAction?.row.original || null}
- />
-
- <DeleteLegalWorksDialog
- open={rowAction?.type === "delete"}
- onOpenChange={(open) => !open && setRowAction(null)}
- legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null);
- refreshData();
- }}
- />
-
- </div>
- </div>
- </div>
- </div>
- </>
- );
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-work-detail-dialog.tsx b/lib/legal-review/status/legal-work-detail-dialog.tsx
deleted file mode 100644
index 23ceccb2..00000000
--- a/lib/legal-review/status/legal-work-detail-dialog.tsx
+++ /dev/null
@@ -1,409 +0,0 @@
-"use client";
-
-import * as React from "react";
-import {
- Eye,
- FileText,
- Building,
- User,
- Calendar,
- Clock,
- MessageSquare,
- CheckCircle,
- ShieldCheck,
-} from "lucide-react";
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { formatDate } from "@/lib/utils";
-import { LegalWorksDetailView } from "@/db/schema";
-
-// -----------------------------------------------------------------------------
-// TYPES
-// -----------------------------------------------------------------------------
-
-type LegalWorkData = LegalWorksDetailView;
-
-interface LegalWorkDetailDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- work: LegalWorkData | null;
-}
-
-// -----------------------------------------------------------------------------
-// HELPERS
-// -----------------------------------------------------------------------------
-
-// 상태별 배지 스타일
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "검토요청":
- return "bg-blue-100 text-blue-800 border-blue-200";
- case "담당자배정":
- return "bg-yellow-100 text-yellow-800 border-yellow-200";
- case "검토중":
- return "bg-orange-100 text-orange-800 border-orange-200";
- case "답변완료":
- return "bg-green-100 text-green-800 border-green-200";
- case "재검토요청":
- return "bg-purple-100 text-purple-800 border-purple-200";
- case "보류":
- return "bg-gray-100 text-gray-800 border-gray-200";
- case "취소":
- return "bg-red-100 text-red-800 border-red-200";
- default:
- return "bg-gray-100 text-gray-800 border-gray-200";
- }
-};
-
-export function LegalWorkDetailDialog({
- open,
- onOpenChange,
- work,
-}: LegalWorkDetailDialogProps) {
- if (!work) return null;
-
- // ---------------------------------------------------------------------------
- // CONDITIONAL FLAGS
- // ---------------------------------------------------------------------------
-
- const isLegalReview = work.reviewDepartment === "법무검토";
- const isCompliance = work.reviewDepartment === "준법문의";
-
- const isDomesticContract = work.inquiryType === "국내계약";
- const isDomesticAdvisory = work.inquiryType === "국내자문";
- const isOverseasContract = work.inquiryType === "해외계약";
- const isOverseasAdvisory = work.inquiryType === "해외자문";
-
- const isContractTypeActive =
- isDomesticContract || isOverseasContract || isOverseasAdvisory;
- const isDomesticContractFieldsActive = isDomesticContract;
- const isFactualRelationActive = isDomesticAdvisory || isOverseasAdvisory;
- const isOverseasFieldsActive = isOverseasContract || isOverseasAdvisory;
-
- // ---------------------------------------------------------------------------
- // RENDER
- // ---------------------------------------------------------------------------
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5" /> 법무업무 상세보기
- </DialogTitle>
- <DialogDescription>
- 법무업무 #{work.id}의 상세 정보를 확인합니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- {/* 본문 */}
- <ScrollArea className="flex-1 p-6">
- <div className="space-y-6">
- {/* 1. 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <FileText className="h-5 w-5" /> 기본 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-6 text-sm">
- <div className="space-y-4">
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">업무 ID:</span>
- <Badge variant="outline">#{work.id}</Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">구분:</span>
- <Badge
- variant={
- work.category === "CP"
- ? "default"
- : work.category === "GTC"
- ? "secondary"
- : "outline"
- }
- >
- {work.category}
- </Badge>
- {work.isUrgent && (
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- )}
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">상태:</span>
- <Badge
- className={getStatusBadgeVariant(work.status)}
- variant="outline"
- >
- {work.status}
- </Badge>
- </div>
- </div>
- <div className="space-y-4">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">벤더:</span>
- <span>
- {work.vendorCode} - {work.vendorName}
- </span>
- </div>
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">의뢰일:</span>
- <span>{formatDate(work.consultationDate, "KR")}</span>
- </div>
- <div className="flex items-center gap-2">
- <Clock className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">답변요청일:</span>
- <span>{formatDate(work.requestDate, "KR")}</span>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* 2. 담당자 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <User className="h-5 w-5" /> 담당자 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-6 text-sm">
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">검토요청자</span>
- <p>{work.reviewer || "미지정"}</p>
- </div>
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">법무답변자</span>
- <p>{work.legalResponder || "미배정"}</p>
- </div>
- {work.expectedAnswerDate && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">답변예정일</span>
- <p>{formatDate(work.expectedAnswerDate, "KR")}</p>
- </div>
- )}
- {work.legalCompletionDate && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">법무완료일</span>
- <p>{formatDate(work.legalCompletionDate, "KR")}</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
-
- {/* 3. 법무업무 상세 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <ShieldCheck className="h-5 w-5" /> 법무업무 상세 정보
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">검토부문</span>
- <Badge variant="outline">{work.reviewDepartment}</Badge>
- </div>
- {work.inquiryType && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">문의종류</span>
- <Badge variant="secondary">{work.inquiryType}</Badge>
- </div>
- )}
- {isCompliance && (
- <div className="space-y-2 col-span-2">
- <span className="font-medium text-muted-foreground">공개여부</span>
- <Badge variant={work.isPublic ? "default" : "outline"}>
- {work.isPublic ? "공개" : "비공개"}
- </Badge>
- </div>
- )}
- </div>
-
- {/* 법무검토 전용 필드 */}
- {isLegalReview && (
- <>
- {work.contractProjectName && (
- <>
- <Separator className="my-2" />
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">
- 계약명 / 프로젝트명
- </span>
- <p>{work.contractProjectName}</p>
- </div>
- </>
- )}
-
- {/* 계약서 종류 */}
- {isContractTypeActive && work.contractType && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">계약서 종류</span>
- <Badge variant="outline" className="max-w-max">
- {work.contractType}
- </Badge>
- </div>
- )}
-
- {/* 국내계약 전용 필드 */}
- {isDomesticContractFieldsActive && (
- <div className="grid grid-cols-2 gap-6 mt-4">
- {work.contractCounterparty && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">
- 계약상대방
- </span>
- <p>{work.contractCounterparty}</p>
- </div>
- )}
- {work.counterpartyType && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">
- 계약상대방 구분
- </span>
- <p>{work.counterpartyType}</p>
- </div>
- )}
- {work.contractPeriod && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">계약기간</span>
- <p>{work.contractPeriod}</p>
- </div>
- )}
- {work.contractAmount && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">계약금액</span>
- <p>{work.contractAmount}</p>
- </div>
- )}
- </div>
- )}
-
- {/* 사실관계 */}
- {isFactualRelationActive && work.factualRelation && (
- <div className="space-y-2 mt-4">
- <span className="font-medium text-muted-foreground">사실관계</span>
- <p className="whitespace-pre-wrap">{work.factualRelation}</p>
- </div>
- )}
-
- {/* 해외 전용 필드 */}
- {isOverseasFieldsActive && (
- <div className="grid grid-cols-2 gap-6 mt-4">
- {work.projectNumber && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">프로젝트번호</span>
- <p>{work.projectNumber}</p>
- </div>
- )}
- {work.shipownerOrderer && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">선주 / 발주처</span>
- <p>{work.shipownerOrderer}</p>
- </div>
- )}
- {work.projectType && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">프로젝트종류</span>
- <p>{work.projectType}</p>
- </div>
- )}
- {work.governingLaw && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">준거법</span>
- <p>{work.governingLaw}</p>
- </div>
- )}
- </div>
- )}
- </>
- )}
- </CardContent>
- </Card>
-
- {/* 4. 요청 내용 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <MessageSquare className="h-5 w-5" /> 요청 내용
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- {work.title && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">제목</span>
- <p className="font-medium">{work.title}</p>
- </div>
- )}
- <Separator />
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">상세 내용</span>
- <div className="bg-muted/30 rounded-lg p-4">
- {work.requestContent ? (
- <div className="prose prose-sm max-w-none">
- <div
- dangerouslySetInnerHTML={{ __html: work.requestContent }}
- />
- </div>
- ) : (
- <p className="italic text-muted-foreground">요청 내용이 없습니다.</p>
- )}
- </div>
- </div>
- {work.attachmentCount > 0 && (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4" /> 첨부파일 {work.attachmentCount}개
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 5. 답변 내용 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <CheckCircle className="h-5 w-5" /> 답변 내용
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- <div className="bg-green-50 border border-green-200 rounded-lg p-4">
- {work.responseContent ? (
- <div className="prose prose-sm max-w-none">
- <div
- dangerouslySetInnerHTML={{ __html: work.responseContent }}
- />
- </div>
- ) : (
- <p className="italic text-muted-foreground">
- 아직 답변이 등록되지 않았습니다.
- </p>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </ScrollArea>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/lib/legal-review/status/legal-work-filter-sheet.tsx b/lib/legal-review/status/legal-work-filter-sheet.tsx
deleted file mode 100644
index 4ac877a9..00000000
--- a/lib/legal-review/status/legal-work-filter-sheet.tsx
+++ /dev/null
@@ -1,897 +0,0 @@
-"use client"
-
-import { useTransition, useState } from "react"
-import { useRouter } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { LEGAL_WORK_FILTER_OPTIONS } from "@/types/legal"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// 법무업무 필터 스키마 정의
-const legalWorkFilterSchema = z.object({
- category: z.string().optional(),
- status: z.string().optional(),
- isUrgent: z.string().optional(),
- reviewDepartment: z.string().optional(),
- inquiryType: z.string().optional(),
- reviewer: z.string().optional(),
- legalResponder: z.string().optional(),
- vendorCode: z.string().optional(),
- vendorName: z.string().optional(),
- requestDateFrom: z.string().optional(),
- requestDateTo: z.string().optional(),
- consultationDateFrom: z.string().optional(),
- consultationDateTo: z.string().optional(),
-})
-
-type LegalWorkFilterFormValues = z.infer<typeof legalWorkFilterSchema>
-
-interface LegalWorkFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void;
- isLoading?: boolean;
-}
-
-export function LegalWorkFilterSheet({
- isOpen,
- onClose,
- onFiltersApply,
- isLoading = false
-}: LegalWorkFilterSheetProps) {
- const router = useRouter()
- const [isPending, startTransition] = useTransition()
- const [joinOperator, setJoinOperator] = useState<"and" | "or">("and")
-
- // 폼 상태 초기화
- const form = useForm<LegalWorkFilterFormValues>({
- resolver: zodResolver(legalWorkFilterSchema),
- defaultValues: {
- category: "",
- status: "",
- isUrgent: "",
- reviewDepartment: "",
- inquiryType: "",
- reviewer: "",
- legalResponder: "",
- vendorCode: "",
- vendorName: "",
- requestDateFrom: "",
- requestDateTo: "",
- consultationDateFrom: "",
- consultationDateTo: "",
- },
- })
-
- // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달
- async function onSubmit(data: LegalWorkFilterFormValues) {
- startTransition(async () => {
- try {
- const newFilters = []
-
- // 구분 필터
- if (data.category?.trim()) {
- newFilters.push({
- id: "category",
- value: data.category.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 상태 필터
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 긴급여부 필터
- if (data.isUrgent?.trim()) {
- newFilters.push({
- id: "isUrgent",
- value: data.isUrgent.trim() === "true",
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 검토부문 필터
- if (data.reviewDepartment?.trim()) {
- newFilters.push({
- id: "reviewDepartment",
- value: data.reviewDepartment.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 문의종류 필터
- if (data.inquiryType?.trim()) {
- newFilters.push({
- id: "inquiryType",
- value: data.inquiryType.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 요청자 필터
- if (data.reviewer?.trim()) {
- newFilters.push({
- id: "reviewer",
- value: data.reviewer.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- // 법무답변자 필터
- if (data.legalResponder?.trim()) {
- newFilters.push({
- id: "legalResponder",
- value: data.legalResponder.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- // 벤더 코드 필터
- if (data.vendorCode?.trim()) {
- newFilters.push({
- id: "vendorCode",
- value: data.vendorCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- // 벤더명 필터
- if (data.vendorName?.trim()) {
- newFilters.push({
- id: "vendorName",
- value: data.vendorName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- // 검토 요청일 범위 필터
- if (data.requestDateFrom?.trim() && data.requestDateTo?.trim()) {
- // 범위 필터 (시작일과 종료일 모두 있는 경우)
- newFilters.push({
- id: "requestDate",
- value: [data.requestDateFrom.trim(), data.requestDateTo.trim()],
- type: "date",
- operator: "between",
- rowId: generateId()
- })
- } else if (data.requestDateFrom?.trim()) {
- // 시작일만 있는 경우 (이후 날짜)
- newFilters.push({
- id: "requestDate",
- value: data.requestDateFrom.trim(),
- type: "date",
- operator: "gte",
- rowId: generateId()
- })
- } else if (data.requestDateTo?.trim()) {
- // 종료일만 있는 경우 (이전 날짜)
- newFilters.push({
- id: "requestDate",
- value: data.requestDateTo.trim(),
- type: "date",
- operator: "lte",
- rowId: generateId()
- })
- }
-
- // 의뢰일 범위 필터
- if (data.consultationDateFrom?.trim() && data.consultationDateTo?.trim()) {
- // 범위 필터 (시작일과 종료일 모두 있는 경우)
- newFilters.push({
- id: "consultationDate",
- value: [data.consultationDateFrom.trim(), data.consultationDateTo.trim()],
- type: "date",
- operator: "between",
- rowId: generateId()
- })
- } else if (data.consultationDateFrom?.trim()) {
- // 시작일만 있는 경우 (이후 날짜)
- newFilters.push({
- id: "consultationDate",
- value: data.consultationDateFrom.trim(),
- type: "date",
- operator: "gte",
- rowId: generateId()
- })
- } else if (data.consultationDateTo?.trim()) {
- // 종료일만 있는 경우 (이전 날짜)
- newFilters.push({
- id: "consultationDate",
- value: data.consultationDateTo.trim(),
- type: "date",
- operator: "lte",
- rowId: generateId()
- })
- }
-
- console.log("=== 생성된 필터들 ===", newFilters);
- console.log("=== 조인 연산자 ===", joinOperator);
-
- // ✅ 부모 컴포넌트에 필터 전달
- onFiltersApply(newFilters, joinOperator);
-
- console.log("=== 필터 적용 완료 ===");
- } catch (error) {
- console.error("법무업무 필터 적용 오류:", error);
- }
- })
- }
-
- // ✅ 필터 초기화 핸들러
- function handleReset() {
- // 1. 폼 초기화
- form.reset({
- category: "",
- status: "",
- isUrgent: "",
- reviewDepartment: "",
- inquiryType: "",
- reviewer: "",
- legalResponder: "",
- vendorCode: "",
- vendorName: "",
- requestDateFrom: "",
- requestDateTo: "",
- consultationDateFrom: "",
- consultationDateTo: "",
- });
-
- // 2. 조인 연산자 초기화
- setJoinOperator("and");
-
- // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정)
- const currentUrl = new URL(window.location.href);
- const newSearchParams = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 초기화
- newSearchParams.set("filters", JSON.stringify([]));
- newSearchParams.set("joinOperator", "and");
- newSearchParams.set("page", "1");
- newSearchParams.delete("search"); // 검색어 제거
-
- // URL 업데이트
- router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
-
- // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해)
- onFiltersApply([], "and");
-
- console.log("=== 필터 완전 초기화 완료 ===");
- }
-
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">법무업무 검색 필터</h3>
- <Button
- variant="ghost"
- size="icon"
- onClick={onClose}
- className="h-8 w-8"
- >
- <X className="size-4" />
- </Button>
- </div>
-
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-4 pt-2">
-
- {/* 구분 */}
- <FormField
- control={form.control}
- name="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="구분 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("category", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.categories.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 상태 */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>상태</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="상태 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.statuses.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>긴급여부</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="긴급여부 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("isUrgent", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="true">긴급</SelectItem>
- <SelectItem value="false">일반</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 검토부문 */}
- <FormField
- control={form.control}
- name="reviewDepartment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>검토부문</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="검토부문 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("reviewDepartment", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.reviewDepartments.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 문의종류 */}
- <FormField
- control={form.control}
- name="inquiryType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문의종류</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="문의종류 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("inquiryType", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.inquiryTypes.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요청자 */}
- <FormField
- control={form.control}
- name="reviewer"
- render={({ field }) => (
- <FormItem>
- <FormLabel>요청자</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="요청자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("reviewer", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 법무답변자 */}
- <FormField
- control={form.control}
- name="legalResponder"
- render={({ field }) => (
- <FormItem>
- <FormLabel>법무답변자</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="법무답변자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("legalResponder", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더 코드 */}
- <FormField
- control={form.control}
- name="vendorCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더 코드</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="벤더 코드 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("vendorCode", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더명 */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="벤더명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("vendorName", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 검토 요청일 범위 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">검토 요청일</label>
-
- {/* 시작일 */}
- <FormField
- control={form.control}
- name="requestDateFrom"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="시작일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("requestDateFrom", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 종료일 */}
- <FormField
- control={form.control}
- name="requestDateTo"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="종료일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("requestDateTo", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 의뢰일 범위 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">의뢰일</label>
-
- {/* 시작일 */}
- <FormField
- control={form.control}
- name="consultationDateFrom"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="시작일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("consultationDateFrom", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 종료일 */}
- <FormField
- control={form.control}
- name="consultationDateTo"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="종료일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("consultationDateTo", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- </div>
- </div>
-
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending}
- className="px-4"
- >
- 초기화
- </Button>
- <Button
- type="submit"
- variant="default"
- disabled={isPending || isLoading}
- className="px-4 bg-blue-600 hover:bg-blue-700 text-white"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? "조회 중..." : "조회"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-works-columns.tsx b/lib/legal-review/status/legal-works-columns.tsx
deleted file mode 100644
index c94b414d..00000000
--- a/lib/legal-review/status/legal-works-columns.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-// components/legal-works/legal-works-columns.tsx
-"use client";
-
-import * as React from "react";
-import { type ColumnDef } from "@tanstack/react-table";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Ellipsis, Paperclip } from "lucide-react";
-
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
-import type { DataTableRowAction } from "@/types/table";
-import { formatDate } from "@/lib/utils";
-import { LegalWorksDetailView } from "@/db/schema";
-
-// ────────────────────────────────────────────────────────────────────────────
-// 타입
-// ────────────────────────────────────────────────────────────────────────────
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
- >;
-}
-
-// ────────────────────────────────────────────────────────────────────────────
-// 헬퍼
-// ────────────────────────────────────────────────────────────────────────────
-const statusVariant = (status: string) => {
- const map: Record<string, string> = {
- 검토요청: "bg-blue-100 text-blue-800 border-blue-200",
- 담당자배정: "bg-yellow-100 text-yellow-800 border-yellow-200",
- 검토중: "bg-orange-100 text-orange-800 border-orange-200",
- 답변완료: "bg-green-100 text-green-800 border-green-200",
- 재검토요청: "bg-purple-100 text-purple-800 border-purple-200",
- 보류: "bg-gray-100 text-gray-800 border-gray-200",
- 취소: "bg-red-100 text-red-800 border-red-200",
- };
- return map[status] ?? "bg-gray-100 text-gray-800 border-gray-200";
-};
-
-const categoryBadge = (category: string) => (
- <Badge
- variant={
- category === "CP" ? "default" : category === "GTC" ? "secondary" : "outline"
- }
- >
- {category}
- </Badge>
-);
-
-const urgentBadge = (isUrgent: boolean) =>
- isUrgent ? (
- <Badge variant="destructive" className="text-xs px-1 py-0">
- 긴급
- </Badge>
- ) : null;
-
-const header = (title: string) =>
- ({ column }: { column: any }) =>
- <DataTableColumnHeaderSimple column={column} title={title} />;
-
-// ────────────────────────────────────────────────────────────────────────────
-// 기본 컬럼
-// ────────────────────────────────────────────────────────────────────────────
-const BASE_COLUMNS: ColumnDef<LegalWorksDetailView>[] = [
- // 선택 체크박스
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
- aria-label="select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(v) => row.toggleSelected(!!v)}
- aria-label="select row"
- className="translate-y-0.5"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- size: 40,
- },
-
- // 번호, 구분, 상태
- {
- accessorKey: "id",
- header: header("No."),
- cell: ({ row }) => (
- <div className="w-[60px] text-center font-medium">{row.getValue("id")}</div>
- ),
- size: 80,
- },
- {
- accessorKey: "category",
- header: header("구분"),
- cell: ({ row }) => categoryBadge(row.getValue("category")),
- size: 80,
- },
- {
- accessorKey: "status",
- header: header("상태"),
- cell: ({ row }) => (
- <Badge className={statusVariant(row.getValue("status"))} variant="outline">
- {row.getValue("status")}
- </Badge>
- ),
- size: 120,
- },
-
- // 벤더 코드·이름
- {
- accessorKey: "vendorCode",
- header: header("벤더 코드"),
- cell: ({ row }) => <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>,
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: header("벤더명"),
- cell: ({ row }) => {
- const name = row.getValue<string>("vendorName");
- return (
- <div className="flex items-center gap-2 truncate max-w-[200px]" title={name}>
- {urgentBadge(row.original.isUrgent)}
- {name}
- </div>
- );
- },
- size: 200,
- },
-
- // 날짜·첨부
- {
- accessorKey: "requestDate",
- header: header("답변요청일"),
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.getValue("requestDate"), "KR")}</span>
- ),
- size: 100,
- },
- {
- accessorKey: "hasAttachment",
- header: header("첨부"),
- cell: ({ row }) =>
- row.getValue<boolean>("hasAttachment") ? (
- <Paperclip className="h-4 w-4 text-muted-foreground" />
- ) : (
- <span className="text-muted-foreground">-</span>
- ),
- size: 60,
- enableSorting: false,
- },
-];
-
-// ────────────────────────────────────────────────────────────────────────────
-// 액션 컬럼
-// ────────────────────────────────────────────────────────────────────────────
-const createActionsColumn = (
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
- >
-): ColumnDef<LegalWorksDetailView> => ({
- id: "actions",
- enableHiding: false,
- size: 40,
- minSize: 40,
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" />
- </Button>
- </DropdownMenuTrigger>
-
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "view" })}>
- 상세보기
- </DropdownMenuItem>
- {row.original.status === "신규등록" && (
- <>
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}>
- 편집
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}>
- 삭제하기
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- ),
-});
-
-// ────────────────────────────────────────────────────────────────────────────
-// 메인 함수
-// ────────────────────────────────────────────────────────────────────────────
-export function getLegalWorksColumns({
- setRowAction,
-}: GetColumnsProps): ColumnDef<LegalWorksDetailView>[] {
- return [...BASE_COLUMNS, createActionsColumn(setRowAction)];
-}
diff --git a/lib/legal-review/status/legal-works-toolbar-actions.tsx b/lib/legal-review/status/legal-works-toolbar-actions.tsx
deleted file mode 100644
index 82fbc80a..00000000
--- a/lib/legal-review/status/legal-works-toolbar-actions.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import {
- Plus,
- Send,
- Download,
- RefreshCw,
- FileText,
- MessageSquare
-} from "lucide-react"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { CreateLegalWorkDialog } from "./create-legal-work-dialog"
-import { RequestReviewDialog } from "./request-review-dialog"
-import { exportTableToExcel } from "@/lib/export"
-import { getLegalWorks } from "../service"
-import { LegalWorksDetailView } from "@/db/schema"
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface LegalWorksTableToolbarActionsProps {
- table: Table<LegalWorkData>
- onRefresh?: () => void
-}
-
-export function LegalWorksTableToolbarActions({
- table,
- onRefresh
-}: LegalWorksTableToolbarActionsProps) {
- const [isLoading, setIsLoading] = React.useState(false)
- const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
- const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false)
- const router = useRouter()
- const { data: session } = useSession()
-
- // 사용자 ID 가져오기
- const userId = React.useMemo(() => {
- return session?.user?.id ? Number(session.user.id) : 1
- }, [session])
-
- // 선택된 행들 - 단일 선택만 허용
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const hasSelection = selectedRows.length > 0
- const isSingleSelection = selectedRows.length === 1
- const isMultipleSelection = selectedRows.length > 1
-
- // 선택된 단일 work
- const selectedWork = isSingleSelection ? selectedRows[0].original : null
-
- // const canDeleateReview = selectedRows.filter(v=>v.status === '신규등록')
-
-
- const deletableRows = React.useMemo(() => {
- return selectedRows.filter(row => {
- const status = row.original.status
- return status ==="신규등록"
- })
- }, [selectedRows])
-
- const hasDeletableRows = deletableRows.length > 0
-
- // 선택된 work의 상태 확인
- const canRequestReview = selectedWork?.status === "신규등록"
- const canAssign = selectedWork?.status === "신규등록"
-
- // ----------------------------------------------------------------
- // 신규 생성
- // ----------------------------------------------------------------
- const handleCreateNew = React.useCallback(() => {
- setCreateDialogOpen(true)
- }, [])
-
- // ----------------------------------------------------------------
- // 검토 요청 (단일 선택만)
- // ----------------------------------------------------------------
- const handleRequestReview = React.useCallback(() => {
- if (!isSingleSelection) {
- toast.error("검토요청은 한 건씩만 가능합니다. 하나의 항목만 선택해주세요.")
- return
- }
-
- if (!canRequestReview) {
- toast.error("신규등록 상태인 항목만 검토요청이 가능합니다.")
- return
- }
-
- setReviewDialogOpen(true)
- }, [isSingleSelection, canRequestReview])
-
- // ----------------------------------------------------------------
- // 다이얼로그 성공 핸들러
- // ----------------------------------------------------------------
- const handleActionSuccess = React.useCallback(() => {
- table.resetRowSelection()
- onRefresh?.()
- router.refresh()
- }, [table, onRefresh, router])
-
- // ----------------------------------------------------------------
- // 내보내기 핸들러
- // ----------------------------------------------------------------
- const handleExport = React.useCallback(() => {
- exportTableToExcel(table, {
- filename: "legal-works-list",
- excludeColumns: ["select", "actions"],
- })
- }, [table])
-
- // ----------------------------------------------------------------
- // 새로고침 핸들러
- // ----------------------------------------------------------------
- const handleRefresh = React.useCallback(async () => {
- setIsLoading(true)
- try {
- onRefresh?.()
- toast.success("데이터를 새로고침했습니다.")
- } catch (error) {
- console.error("새로고침 오류:", error)
- toast.error("새로고침 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }, [onRefresh])
-
- return (
- <>
- <div className="flex items-center gap-2">
-
- {hasDeletableRows&&(
- <DeleteLegalWorksDialog
- legalWorks={deletableRows.map(row => row.original)}
- showTrigger={hasDeletableRows}
- onSuccess={() => {
- table.toggleAllRowsSelected(false)
- // onRefresh?.()
- }}
- />
- )}
- {/* 신규 생성 버튼 */}
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- onClick={handleCreateNew}
- disabled={isLoading}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">신규 등록</span>
- </Button>
-
- {/* 유틸리티 버튼들 */}
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- disabled={isLoading}
- className="gap-2"
- >
- <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
- <span className="hidden sm:inline">새로고침</span>
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleExport}
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">내보내기</span>
- </Button>
- </div>
-
- {/* 선택된 항목 액션 버튼들 */}
- {hasSelection && (
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- {/* 다중 선택 경고 메시지 */}
- {isMultipleSelection && (
- <div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded border border-amber-200">
- 검토요청은 한 건씩만 가능합니다
- </div>
- )}
-
- {/* 검토 요청 버튼 (단일 선택시만) */}
- {isSingleSelection && (
- <Button
- variant="default"
- size="sm"
- className="gap-2 bg-blue-600 hover:bg-blue-700"
- onClick={handleRequestReview}
- disabled={isLoading || !canRequestReview}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {canRequestReview ? "검토요청" : "검토불가"}
- </span>
- </Button>
- )}
-
- {/* 추가 액션 드롭다운 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isLoading}
- >
- <MessageSquare className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">추가 작업</span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => toast.info("담당자 배정 기능을 준비 중입니다.")}
- disabled={!isSingleSelection || !canAssign}
- >
- <FileText className="size-4 mr-2" />
- 담당자 배정
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={() => toast.info("상태 변경 기능을 준비 중입니다.")}
- disabled={!isSingleSelection}
- >
- <RefreshCw className="size-4 mr-2" />
- 상태 변경
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )}
-
- {/* 선택된 항목 정보 표시 */}
- {hasSelection && (
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- <div className="text-xs text-muted-foreground">
- {isSingleSelection ? (
- <>
- 선택: #{selectedWork?.id} ({selectedWork?.category})
- {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`}
- {selectedWork?.status && ` | ${selectedWork.status}`}
- </>
- ) : (
- `선택: ${selectedRows.length}건 (개별 처리 필요)`
- )}
- </div>
- </div>
- )}
- </div>
-
- {/* 다이얼로그들 */}
- {/* 신규 생성 다이얼로그 */}
- <CreateLegalWorkDialog
- open={createDialogOpen}
- onOpenChange={setCreateDialogOpen}
- onSuccess={handleActionSuccess}
- onDataChange={onRefresh}
- />
-
- {/* 검토 요청 다이얼로그 - 단일 work 전달 */}
- {selectedWork && (
- <RequestReviewDialog
- open={reviewDialogOpen}
- onOpenChange={setReviewDialogOpen}
- work={selectedWork} // 단일 객체로 변경
- onSuccess={handleActionSuccess}
- />
- )}
- </>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx
deleted file mode 100644
index d99fc0e3..00000000
--- a/lib/legal-review/status/request-review-dialog.tsx
+++ /dev/null
@@ -1,983 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { Loader2, Send, FileText, Clock, Upload, X, Building, User, Calendar } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Switch } from "@/components/ui/switch"
-import TiptapEditor from "@/components/qna/tiptap-editor"
-import { canRequestReview, requestReview } from "../service"
-import { LegalWorksDetailView } from "@/db/schema"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface RequestReviewDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- work: LegalWorkData | null
- onSuccess?: () => void
-}
-
-// 검토요청 폼 스키마
-const requestReviewSchema = z.object({
- // 기본 검토 설정
- dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"),
- assignee: z.string().optional(),
- notificationMethod: z.enum(["email", "internal", "both"]).default("both"),
-
- // 법무업무 상세 정보
- reviewDepartment: z.enum(["준법문의", "법무검토"]),
- inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(),
-
- // 공통 필드
- title: z.string().min(1, "제목을 선택해주세요"),
- requestContent: z.string().min(1, "요청내용을 입력해주세요"),
-
- // 준법문의 전용 필드
- isPublic: z.boolean().default(false),
-
- // 법무검토 전용 필드들
- contractProjectName: z.string().optional(),
- contractType: z.string().optional(),
- contractCounterparty: z.string().optional(),
- counterpartyType: z.enum(["법인", "개인"]).optional(),
- contractPeriod: z.string().optional(),
- contractAmount: z.string().optional(),
- factualRelation: z.string().optional(),
- projectNumber: z.string().optional(),
- shipownerOrderer: z.string().optional(),
- projectType: z.string().optional(),
- governingLaw: z.string().optional(),
-}).refine((data) => {
- // 법무검토 선택시 문의종류 필수
- if (data.reviewDepartment === "법무검토" && !data.inquiryType) {
- return false;
- }
- return true;
-}, {
- message: "법무검토 선택시 문의종류를 선택해주세요",
- path: ["inquiryType"]
-});
-
-type RequestReviewFormValues = z.infer<typeof requestReviewSchema>
-
-export function RequestReviewDialog({
- open,
- onOpenChange,
- work,
- onSuccess
-}: RequestReviewDialogProps) {
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [attachments, setAttachments] = React.useState<File[]>([])
- const [editorContent, setEditorContent] = React.useState("")
- const [canRequest, setCanRequest] = React.useState(true)
- const [requestCheckMessage, setRequestCheckMessage] = React.useState("")
- const [isCustomTitle, setIsCustomTitle] = React.useState(false)
-
- // work의 category에 따라 기본 reviewDepartment 결정
- const getDefaultReviewDepartment = () => {
- return work?.category === "CP" ? "준법문의" : "법무검토"
- }
-
- const form = useForm<RequestReviewFormValues>({
- resolver: zodResolver(requestReviewSchema),
- defaultValues: {
- dueDate: "",
- assignee: "",
- notificationMethod: "both",
- reviewDepartment: getDefaultReviewDepartment(),
- title: getDefaultReviewDepartment() === "준법문의" ? "CP검토" : "GTC검토",
- requestContent: "",
- isPublic: false,
- },
- })
-
- // work 변경시 검토요청 가능 여부 확인
- React.useEffect(() => {
- if (work && open) {
- canRequestReview(work.id).then((result) => {
- setCanRequest(result.canRequest)
- setRequestCheckMessage(result.reason || "")
- })
-
- const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토"
- form.setValue("reviewDepartment", defaultDepartment)
- }
- }, [work, open, form])
-
- // 검토부문 감시
- const reviewDepartment = form.watch("reviewDepartment")
- const inquiryType = form.watch("inquiryType")
- const titleValue = form.watch("title")
-
- // 조건부 필드 활성화 로직
- const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType)
- const isDomesticContractFieldsActive = inquiryType === "국내계약"
- const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType)
- const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType)
-
- // 제목 "기타" 선택 여부 확인
- // const isTitleOther = titleValue === "기타"
-
- // 검토부문 변경시 관련 필드 초기화
- React.useEffect(() => {
- if (reviewDepartment === "준법문의") {
- setIsCustomTitle(false)
- form.setValue("inquiryType", undefined)
- // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
- const currentTitle = form.getValues("title")
- if (!currentTitle || currentTitle === "GTC검토") {
- form.setValue("title", "CP검토")
- }
- // 법무검토 전용 필드들 초기화
- form.setValue("contractProjectName", "")
- form.setValue("contractType", "")
- form.setValue("contractCounterparty", "")
- form.setValue("counterpartyType", undefined)
- form.setValue("contractPeriod", "")
- form.setValue("contractAmount", "")
- form.setValue("factualRelation", "")
- form.setValue("projectNumber", "")
- form.setValue("shipownerOrderer", "")
- form.setValue("projectType", "")
- form.setValue("governingLaw", "")
- } else {
- setIsCustomTitle(false)
- // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
- const currentTitle = form.getValues("title")
- if (!currentTitle || currentTitle === "CP검토") {
- form.setValue("title", "GTC검토")
- }
- form.setValue("isPublic", false)
- }
- }, [reviewDepartment, form])
-
- // 문의종류 변경시 관련 필드 초기화
- React.useEffect(() => {
- if (inquiryType) {
- // 계약서 종류 초기화 (옵션이 달라지므로)
- form.setValue("contractType", "")
-
- // 조건에 맞지 않는 필드들 초기화
- if (!isDomesticContractFieldsActive) {
- form.setValue("contractCounterparty", "")
- form.setValue("counterpartyType", undefined)
- form.setValue("contractPeriod", "")
- form.setValue("contractAmount", "")
- }
-
- if (!isFactualRelationActive) {
- form.setValue("factualRelation", "")
- }
-
- if (!isOverseasFieldsActive) {
- form.setValue("projectNumber", "")
- form.setValue("shipownerOrderer", "")
- form.setValue("projectType", "")
- form.setValue("governingLaw", "")
- }
- }
- }, [inquiryType, isDomesticContractFieldsActive, isFactualRelationActive, isOverseasFieldsActive, form])
-
- // 에디터 내용이 변경될 때 폼에 반영
- React.useEffect(() => {
- form.setValue("requestContent", editorContent)
- }, [editorContent, form])
-
- // 첨부파일 처리
- const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- const files = Array.from(event.target.files || [])
- setAttachments(prev => [...prev, ...files])
- }
-
- const removeAttachment = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index))
- }
-
- // 폼 제출
- async function onSubmit(data: RequestReviewFormValues) {
- if (!work) return
-
- console.log("Request review data:", data)
- console.log("Work to review:", work)
- console.log("Attachments:", attachments)
- setIsSubmitting(true)
-
- try {
- const result = await requestReview(work.id, data, attachments)
-
- if (result.success) {
- toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`)
- onOpenChange(false)
- handleReset()
- onSuccess?.()
- } else {
- toast.error(result.error || "검토요청 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error requesting review:", error)
- toast.error("검토요청 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 폼 리셋 함수
- const handleReset = () => {
- const defaultDepartment = getDefaultReviewDepartment()
- setIsCustomTitle(false) // 추가
-
- form.reset({
- dueDate: "",
- assignee: "",
- notificationMethod: "both",
- reviewDepartment: defaultDepartment,
- title: defaultDepartment === "준법문의" ? "CP검토" : "GTC검토",
- requestContent: "",
- isPublic: false,
- })
- setAttachments([])
- setEditorContent("")
- }
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- handleReset()
- }
- }
-
- // 제목 옵션 (검토부문에 따라 다름)
- const getTitleOptions = () => {
- if (reviewDepartment === "준법문의") {
- return [
- { value: "CP검토", label: "CP검토" },
- { value: "기타", label: "기타 (직접입력)" }
- ]
- } else {
- return [
- { value: "GTC검토", label: "GTC검토" },
- { value: "기타", label: "기타 (직접입력)" }
- ]
- }
- }
-
- // 계약서 종류 옵션 (문의종류에 따라 다름)
- const getContractTypeOptions = () => {
- if (inquiryType === "국내계약") {
- return [
- { value: "공사도급계약", label: "공사도급계약" },
- { value: "제작납품계약", label: "제작납품계약" },
- { value: "자재매매계약", label: "자재매매계약" },
- { value: "용역위탁계약", label: "용역위탁계약" },
- { value: "기술사용 및 개발계약", label: "기술사용 및 개발계약" },
- { value: "운송 및 자재관리 계약", label: "운송 및 자재관리 계약" },
- { value: "자문 등 위임계약", label: "자문 등 위임계약" },
- { value: "양해각서", label: "양해각서" },
- { value: "양수도 계약", label: "양수도 계약" },
- { value: "합의서", label: "합의서" },
- { value: "공동도급(운영)협약서", label: "공동도급(운영)협약서" },
- { value: "협정서", label: "협정서" },
- { value: "약정서", label: "약정서" },
- { value: "협의서", label: "협의서" },
- { value: "기타", label: "기타" },
- { value: "비밀유지계약서", label: "비밀유지계약서" },
- { value: "분양계약서", label: "분양계약서" },
- ]
- } else {
- // 해외계약/해외자문
- return [
- { value: "Shipbuilding Contract", label: "Shipbuilding Contract" },
- { value: "Offshore Contract (EPCI, FEED)", label: "Offshore Contract (EPCI, FEED)" },
- { value: "Supplementary / Addendum", label: "Supplementary / Addendum" },
- { value: "Subcontract / GTC / PTC / PO", label: "Subcontract / GTC / PTC / PO" },
- { value: "Novation / Assignment", label: "Novation / Assignment" },
- { value: "NDA (Confidential, Secrecy)", label: "NDA (Confidential, Secrecy)" },
- { value: "Warranty", label: "Warranty" },
- { value: "Waiver and Release", label: "Waiver and Release" },
- { value: "Bond (PG, RG, Advanced Payment)", label: "Bond (PG, RG, Advanced Payment)" },
- { value: "MOU / LOI / LOA", label: "MOU / LOI / LOA" },
- { value: "Power of Attorney (POA)", label: "Power of Attorney (POA)" },
- { value: "Commission Agreement", label: "Commission Agreement" },
- { value: "Consortium Agreement", label: "Consortium Agreement" },
- { value: "JV / JDP Agreement", label: "JV / JDP Agreement" },
- { value: "Engineering Service Contract", label: "Engineering Service Contract" },
- { value: "Consultancy Service Agreement", label: "Consultancy Service Agreement" },
- { value: "Purchase / Lease Agreement", label: "Purchase / Lease Agreement" },
- { value: "Financial / Loan / Covenant", label: "Financial / Loan / Covenant" },
- { value: "Other Contract / Agreement", label: "Other Contract / Agreement" },
- ]
- }
- }
-
- // 프로젝트 종류 옵션
- const getProjectTypeOptions = () => {
- return [
- { value: "BARGE VESSEL", label: "BARGE VESSEL" },
- { value: "BULK CARRIER", label: "BULK CARRIER" },
- { value: "CHEMICAL CARRIER", label: "CHEMICAL CARRIER" },
- { value: "FULL CONTAINER", label: "FULL CONTAINER" },
- { value: "CRUDE OIL TANKER", label: "CRUDE OIL TANKER" },
- { value: "CRUISE SHIP", label: "CRUISE SHIP" },
- { value: "DRILL SHIP", label: "DRILL SHIP" },
- { value: "FIELD DEVELOPMENT SHIP", label: "FIELD DEVELOPMENT SHIP" },
- { value: "FLOATING PRODUCTION STORAGE OFFLOADING", label: "FLOATING PRODUCTION STORAGE OFFLOADING" },
- { value: "CAR-FERRY & PASSENGER VESSEL", label: "CAR-FERRY & PASSENGER VESSEL" },
- { value: "FLOATING STORAGE OFFLOADING", label: "FLOATING STORAGE OFFLOADING" },
- { value: "HEAVY DECK CARGO", label: "HEAVY DECK CARGO" },
- { value: "PRODUCT OIL TANKER", label: "PRODUCT OIL TANKER" },
- { value: "HIGH SPEED LINER", label: "HIGH SPEED LINER" },
- { value: "JACK-UP", label: "JACK-UP" },
- { value: "LIQUEFIED NATURAL GAS CARRIER", label: "LIQUEFIED NATURAL GAS CARRIER" },
- { value: "LIQUEFIED PETROLEUM GAS CARRIER", label: "LIQUEFIED PETROLEUM GAS CARRIER" },
- { value: "MULTIPURPOSE CARGO CARRIER", label: "MULTIPURPOSE CARGO CARRIER" },
- { value: "ORE-BULK-OIL CARRIER", label: "ORE-BULK-OIL CARRIER" },
- { value: "OIL TANKER", label: "OIL TANKER" },
- { value: "OTHER VESSEL", label: "OTHER VESSEL" },
- { value: "PURE CAR CARRIER", label: "PURE CAR CARRIER" },
- { value: "PRODUCT CARRIER", label: "PRODUCT CARRIER" },
- { value: "PLATFORM", label: "PLATFORM" },
- { value: "PUSHER", label: "PUSHER" },
- { value: "REEFER TRANSPORT VESSEL", label: "REEFER TRANSPORT VESSEL" },
- { value: "ROLL-ON ROLL-OFF VESSEL", label: "ROLL-ON ROLL-OFF VESSEL" },
- { value: "SEMI RIG", label: "SEMI RIG" },
- { value: "SUPPLY ANCHOR HANDLING VESSEL", label: "SUPPLY ANCHOR HANDLING VESSEL" },
- { value: "SHUTTLE TANKER", label: "SHUTTLE TANKER" },
- { value: "SUPPLY VESSEL", label: "SUPPLY VESSEL" },
- { value: "TOPSIDE", label: "TOPSIDE" },
- { value: "TUG SUPPLY VESSEL", label: "TUG SUPPLY VESSEL" },
- { value: "VERY LARGE CRUDE OIL CARRIER", label: "VERY LARGE CRUDE OIL CARRIER" },
- { value: "WELL INTERVENTION SHIP", label: "WELL INTERVENTION SHIP" },
- { value: "WIND TURBINE INSTALLATION VESSEL", label: "WIND TURBINE INSTALLATION VESSEL" },
- { value: "기타", label: "기타" },
- ]
- }
-
- if (!work) {
- return null
- }
-
- // 검토요청 불가능한 경우 안내 메시지
- if (!canRequest) {
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2 text-amber-600">
- <FileText className="h-5 w-5" />
- 검토요청 불가
- </DialogTitle>
- <DialogDescription className="pt-4">
- {requestCheckMessage}
- </DialogDescription>
- </DialogHeader>
- <div className="flex justify-end pt-4">
- <Button onClick={() => onOpenChange(false)}>확인</Button>
- </div>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Send className="h-5 w-5" />
- 검토요청 발송
- </DialogTitle>
- <DialogDescription>
- 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto p-6">
- <div className="space-y-6">
- {/* 선택된 업무 정보 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 검토 대상 업무
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <span className="font-medium">업무 ID:</span>
- <Badge variant="outline">#{work.id}</Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium">구분:</span>
- <Badge variant={work.category === "CP" ? "default" : "secondary"}>
- {work.category}
- </Badge>
- {work.isUrgent && (
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- )}
- </div>
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4" />
- <span className="font-medium">벤더:</span>
- <span>{work.vendorCode} - {work.vendorName}</span>
- </div>
- </div>
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <User className="h-4 w-4" />
- <span className="font-medium">요청자:</span>
- <span>{work.reviewer || "미지정"}</span>
- </div>
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4" />
- <span className="font-medium">답변요청일:</span>
- <span>{work.requestDate || "미설정"}</span>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium">상태:</span>
- <Badge variant="outline">{work.status}</Badge>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* 기본 설정 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토 완료 희망일 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-2">
- <Clock className="h-4 w-4" />
- 검토 완료 희망일
- </FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 법무업무 상세 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">법무업무 상세 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토부문 */}
- <FormField
- control={form.control}
- name="reviewDepartment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>검토부문</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="검토부문 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="준법문의">준법문의</SelectItem>
- <SelectItem value="법무검토">법무검토</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 문의종류 (법무검토 선택시만) */}
- {reviewDepartment === "법무검토" && (
- <FormField
- control={form.control}
- name="inquiryType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문의종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="문의종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="국내계약">국내계약</SelectItem>
- <SelectItem value="국내자문">국내자문</SelectItem>
- <SelectItem value="해외계약">해외계약</SelectItem>
- <SelectItem value="해외자문">해외자문</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 제목 - 조건부 렌더링 */}
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- {!isCustomTitle ? (
- // Select 모드
- <Select
- onValueChange={(value) => {
- if (value === "기타") {
- setIsCustomTitle(true)
- field.onChange("") // 빈 값으로 초기화
- } else {
- field.onChange(value)
- }
- }}
- value={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="제목 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getTitleOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : (
- // Input 모드 (기타 선택시)
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <Badge variant="outline" className="text-xs">기타</Badge>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토"
- form.setValue("title", defaultTitle)
- setIsCustomTitle(false) // 상태 초기화
- }}
- className="h-6 text-xs"
- >
- 선택 모드로 돌아가기
- </Button>
- </div>
- <FormControl>
- <Input
- placeholder="제목을 직접 입력하세요"
- value={field.value}
- onChange={(e) => field.onChange(e.target.value)}
- autoFocus
- />
- </FormControl>
- </div>
- )}
- <FormMessage />
- </FormItem>
- )}
-/>
-
- {/* 준법문의 전용 필드들 */}
- {reviewDepartment === "준법문의" && (
- <FormField
- control={form.control}
- name="isPublic"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">공개여부</FormLabel>
- <div className="text-sm text-muted-foreground">
- 준법문의 공개 설정
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- )}
-
- {/* 법무검토 전용 필드들 */}
- {reviewDepartment === "법무검토" && (
- <div className="space-y-4">
- {/* 계약명/프로젝트명 */}
- <FormField
- control={form.control}
- name="contractProjectName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약명/프로젝트명</FormLabel>
- <FormControl>
- <Input placeholder="계약명 또는 프로젝트명 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약서 종류 - 조건부 활성화 */}
- {isContractTypeActive && (
- <FormField
- control={form.control}
- name="contractType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약서 종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="계약서 종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getContractTypeOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 국내계약 전용 필드들 */}
- {isDomesticContractFieldsActive && (
- <div className="grid grid-cols-2 gap-4">
- {/* 계약상대방 */}
- <FormField
- control={form.control}
- name="contractCounterparty"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약상대방</FormLabel>
- <FormControl>
- <Input placeholder="계약상대방 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약상대방 구분 */}
- <FormField
- control={form.control}
- name="counterpartyType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약상대방 구분</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="법인">법인</SelectItem>
- <SelectItem value="개인">개인</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약기간 */}
- <FormField
- control={form.control}
- name="contractPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약기간</FormLabel>
- <FormControl>
- <Input placeholder="계약기간 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약금액 */}
- <FormField
- control={form.control}
- name="contractAmount"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약금액</FormLabel>
- <FormControl>
- <Input placeholder="계약금액 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- )}
-
- {/* 사실관계 - 조건부 활성화 */}
- {isFactualRelationActive && (
- <FormField
- control={form.control}
- name="factualRelation"
- render={({ field }) => (
- <FormItem>
- <FormLabel>사실관계</FormLabel>
- <FormControl>
- <Textarea
- placeholder="사실관계를 상세히 입력해주세요"
- className="min-h-[80px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 해외 관련 필드들 - 조건부 활성화 */}
- {isOverseasFieldsActive && (
- <div className="grid grid-cols-2 gap-4">
- {/* 프로젝트번호 */}
- <FormField
- control={form.control}
- name="projectNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트번호</FormLabel>
- <FormControl>
- <Input placeholder="프로젝트번호 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선주/발주처 */}
- <FormField
- control={form.control}
- name="shipownerOrderer"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선주/발주처</FormLabel>
- <FormControl>
- <Input placeholder="선주/발주처 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트종류 */}
- <FormField
- control={form.control}
- name="projectType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="프로젝트종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getProjectTypeOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 준거법 */}
- <FormField
- control={form.control}
- name="governingLaw"
- render={({ field }) => (
- <FormItem>
- <FormLabel>준거법</FormLabel>
- <FormControl>
- <Input placeholder="준거법 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- )}
- </div>
- )}
-
- {/* 요청내용 - TiptapEditor로 교체 */}
- <FormField
- control={form.control}
- name="requestContent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>요청내용</FormLabel>
- <FormControl>
- <div className="min-h-[250px]">
- <TiptapEditor
- content={editorContent}
- setContent={setEditorContent}
- disabled={isSubmitting}
- height="250px"
- />
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 첨부파일 */}
- <div className="space-y-2">
- <FormLabel>첨부파일</FormLabel>
- <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4">
- <input
- type="file"
- multiple
- onChange={handleFileChange}
- className="hidden"
- id="file-upload"
- />
- <label
- htmlFor="file-upload"
- className="flex flex-col items-center justify-center cursor-pointer"
- >
- <Upload className="h-8 w-8 text-muted-foreground mb-2" />
- <span className="text-sm text-muted-foreground">
- 파일을 선택하거나 여기로 드래그하세요
- </span>
- </label>
- </div>
-
- {/* 선택된 파일 목록 */}
- {attachments.length > 0 && (
- <div className="space-y-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center justify-between bg-muted/50 p-2 rounded">
- <span className="text-sm truncate">{file.name}</span>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t p-6">
- <div className="flex justify-end gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isSubmitting}
- className="bg-blue-600 hover:bg-blue-700"
- >
- {isSubmitting ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 발송 중...
- </>
- ) : (
- <>
- <Send className="mr-2 h-4 w-4" />
- 검토요청 발송
- </>
- )}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/update-legal-work-dialog.tsx b/lib/legal-review/status/update-legal-work-dialog.tsx
deleted file mode 100644
index d9157d3c..00000000
--- a/lib/legal-review/status/update-legal-work-dialog.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { Loader2, Check, ChevronsUpDown, Edit } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Switch } from "@/components/ui/switch"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { cn } from "@/lib/utils"
-import { getVendorsForSelection } from "@/lib/b-rfq/service"
-import { LegalWorksDetailView } from "@/db/schema"
-// import { updateLegalWork } from "../service"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface EditLegalWorkSheetProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- work: LegalWorkData | null
- onSuccess?: () => void
- onDataChange?: () => void
-}
-
-// 편집용 폼 스키마 (신규등록 상태에서만 기본 정보만 편집)
-const editLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
-})
-
-type EditLegalWorkFormValues = z.infer<typeof editLegalWorkSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-export function EditLegalWorkSheet({
- open,
- onOpenChange,
- work,
- onSuccess,
- onDataChange
-}: EditLegalWorkSheetProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- const form = useForm<EditLegalWorkFormValues>({
- resolver: zodResolver(editLegalWorkSchema),
- defaultValues: {
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: "",
- },
- })
-
- // work 데이터가 변경될 때 폼 값 업데이트
- React.useEffect(() => {
- if (work && open) {
- form.reset({
- category: work.category as "CP" | "GTC" | "기타",
- vendorId: work.vendorId || 0,
- isUrgent: work.isUrgent || false,
- requestDate: work.requestDate ? new Date(work.requestDate).toISOString().split('T')[0] : "",
- })
- }
- }, [work, open, form])
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 폼 제출
- async function onSubmit(data: EditLegalWorkFormValues) {
- if (!work) return
-
- console.log("Updating legal work with data:", data)
- setIsSubmitting(true)
-
- try {
- const result = await updateLegalWork(work.id, data)
-
- if (result.success) {
- toast.success(result.data?.message || "법무업무가 성공적으로 수정되었습니다.")
- onOpenChange(false)
- onSuccess?.()
- onDataChange?.()
- router.refresh()
- } else {
- toast.error(result.error || "수정 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error updating legal work:", error)
- toast.error("수정 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 시트 닫기 핸들러
- const handleOpenChange = (openState: boolean) => {
- onOpenChange(openState)
- if (!openState) {
- form.reset()
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
-
- if (!work) {
- return null
- }
-
- return (
- <Sheet open={open} onOpenChange={handleOpenChange}>
- <SheetContent className="w-[600px] sm:w-[800px] p-0 flex flex-col" style={{maxWidth:900}}>
- {/* 고정 헤더 */}
- <SheetHeader className="flex-shrink-0 p-6 border-b">
- <SheetTitle className="flex items-center gap-2">
- <Edit className="h-5 w-5" />
- 법무업무 편집
- </SheetTitle>
- <SheetDescription>
- 법무업무 #{work.id}의 기본 정보를 수정합니다. (신규등록 상태에서만 편집 가능)
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <ScrollArea className="flex-1 p-6">
- <div className="space-y-6">
- {/* 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 구분 */}
- <FormField
- control={form.control}
- name="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="CP">CP</SelectItem>
- <SelectItem value="GTC">GTC</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">긴급 요청</FormLabel>
- <div className="text-sm text-muted-foreground">
- 긴급 처리가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더</FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- >
- {selectedVendor ? (
- <span className="flex items-center gap-2">
- <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
- {selectedVendor.vendorName}
- </span>
- ) : (
- "벤더 선택..."
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput placeholder="벤더 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- <div className="flex items-center gap-2">
- <Badge variant="outline">{vendor.vendorCode}</Badge>
- <span>{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 답변요청일 */}
- <FormField
- control={form.control}
- name="requestDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변요청일</FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 안내 메시지 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardContent className="pt-6">
- <div className="flex items-start gap-3">
- <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
- <div className="space-y-1">
- <p className="text-sm font-medium text-blue-900">
- 편집 제한 안내
- </p>
- <p className="text-sm text-blue-700">
- 기본 정보는 '신규등록' 상태에서만 편집할 수 있습니다. 검토요청이 발송된 후에는 담당자를 통해 변경해야 합니다.
- </p>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- </ScrollArea>
-
- {/* 고정 버튼 영역 */}
- <SheetFooter className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-end gap-3 w-full">
- <SheetClose asChild>
- <Button
- type="button"
- variant="outline"
- disabled={isSubmitting}
- >
- 취소
- </Button>
- </SheetClose>
- <Button
- type="submit"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 저장
- </Button>
- </div>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/validations.ts b/lib/legal-review/validations.ts
deleted file mode 100644
index 4f41016e..00000000
--- a/lib/legal-review/validations.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import {
- createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,
-} from "nuqs/server";
-import * as z from "zod";
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
-import { legalWorksDetailView } from "@/db/schema";
-
-export const SearchParamsCacheLegalWorks = createSearchParamsCache({
- // UI 모드나 플래그 관련
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 (createdAt 기준 내림차순)
- sort: getSortingStateParser<typeof legalWorksDetailView>().withDefault([
- { id: "createdAt", desc: true }]),
-
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- search: parseAsString.withDefault(""),
-});
-export type GetLegalWorksSchema = Awaited<ReturnType<typeof SearchParamsCacheLegalWorks.parse>>;
-
-export const createLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
- expectedAnswerDate: z.string().optional(),
- reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
- });
-
-export type CreateLegalWorkData = z.infer<typeof createLegalWorkSchema>;
- \ No newline at end of file
diff --git a/lib/procurement-rfqs/repository.ts b/lib/procurement-rfqs/repository.ts
deleted file mode 100644
index eb48bc42..00000000
--- a/lib/procurement-rfqs/repository.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// src/lib/tasks/repository.ts
-import db from "@/db/db";
-import { procurementRfqsView } from "@/db/schema";
-import {
- eq,
- inArray,
- not,
- asc,
- desc,
- and,
- ilike,
- gte,
- lte,
- count,
- gt, sql
-} from "drizzle-orm";
-import { PgTransaction } from "drizzle-orm/pg-core";
-
-/**
- * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
- * - 트랜잭션(tx)을 받아서 사용하도록 구현
- */
-export async function selectPORfqs(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
-) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select()
- .from(procurementRfqsView)
- .where(where ?? undefined)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
-}
-/** 총 개수 count */
-export async function countPORfqs(
- tx: PgTransaction<any, any, any>,
- where?: any
-) {
- const res = await tx.select({ count: count() }).from(procurementRfqsView).where(where);
- return res[0]?.count ?? 0;
-}
-
diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts
deleted file mode 100644
index 9cca4c73..00000000
--- a/lib/procurement-rfqs/services.ts
+++ /dev/null
@@ -1,2050 +0,0 @@
-"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-
-import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-
-import { filterColumns } from "@/lib/filter-columns";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { getErrorMessage } from "@/lib/handle-error";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-import { GetPORfqsSchema, GetQuotationsSchema } from "./validations";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count, between } from "drizzle-orm";
-import { incoterms, paymentTerms, prItems, prItemsView, procurementAttachments, procurementQuotationItems, procurementRfqComments, procurementRfqDetails, procurementRfqDetailsView, procurementRfqs, procurementRfqsView, procurementVendorQuotations } from "@/db/schema/procurementRFQ";
-import { countPORfqs, selectPORfqs } from "./repository";
-import { writeFile, mkdir } from "fs/promises"
-import { join } from "path"
-import { v4 as uuidv4 } from "uuid"
-import { items, projects, users, vendors } from "@/db/schema";
-import { formatISO } from "date-fns";
-import { sendEmail } from "../mail/sendEmail";
-import { formatDate } from "../utils";
-
-async function getAuthenticatedUser() {
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user?.id) {
- throw new Error("인증이 필요합니다");
- }
-
- return {
- userId: session.user.id,
- user: session.user
- };
-}
-
-
-export async function getPORfqs(input: GetPORfqsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 기본 필터 처리 - RFQFilterBox에서 오는 필터
- const basicFilters = input.basicFilters || [];
- const basicJoinOperator = input.basicJoinOperator || "and";
-
- // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
- const advancedFilters = input.filters || [];
- const advancedJoinOperator = input.joinOperator || "and";
-
- // 기본 필터 조건 생성
- let basicWhere;
- if (basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: procurementRfqsView,
- filters: basicFilters,
- joinOperator: basicJoinOperator,
- });
- }
-
- // 고급 필터 조건 생성
- let advancedWhere;
- if (advancedFilters.length > 0) {
- advancedWhere = filterColumns({
- table: procurementRfqsView,
- filters: advancedFilters,
- joinOperator: advancedJoinOperator,
- });
- }
-
- // 전역 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(procurementRfqsView.rfqCode, s),
- ilike(procurementRfqsView.projectCode, s),
- ilike(procurementRfqsView.projectName, s),
- ilike(procurementRfqsView.dueDate, s),
- ilike(procurementRfqsView.status, s),
- // 발주담당 검색 추가
- ilike(procurementRfqsView.picCode, s)
- );
- }
-
- // 날짜 범위 필터링을 위한 특별 처리 (RFQFilterBox는 이미 basicFilters에 포함)
- // 이 코드는 기존 처리와의 호환성을 위해 유지
- let dateRangeWhere;
- if (input.filters) {
- const rfqSendDateFilter = input.filters.find(f => f.id === "rfqSendDate" && Array.isArray(f.value));
-
- if (rfqSendDateFilter && Array.isArray(rfqSendDateFilter.value)) {
- const [fromDate, toDate] = rfqSendDateFilter.value;
-
- if (fromDate && toDate) {
- // 시작일과 종료일이 모두 있는 경우
- dateRangeWhere = between(
- procurementRfqsView.rfqSendDate,
- new Date(fromDate),
- new Date(toDate)
- );
- } else if (fromDate) {
- // 시작일만 있는 경우
- dateRangeWhere = sql`${procurementRfqsView.rfqSendDate} >= ${new Date(fromDate)}`;
- }
- }
- }
-
- // 모든 조건 결합
- let whereConditions = [];
- if (basicWhere) whereConditions.push(basicWhere);
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (dateRangeWhere) whereConditions.push(dateRangeWhere);
-
- // 조건이 있을 때만 and() 사용
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
-
-
-
- // 정렬 조건 - 안전하게 처리
- const orderBy =
- input.sort && input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc
- ? desc(procurementRfqsView[item.id])
- : asc(procurementRfqsView[item.id])
- )
- : [desc(procurementRfqsView.updatedAt)]
-
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectPORfqs(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countPORfqs(tx, finalWhere);
- return { data, total };
- });
-
- console.log(total)
-
- console.log("쿼리 결과 데이터:", data.length);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount ,total };
- } catch (err) {
- console.error("getRfqs 에러:", err);
-
- // 에러 세부 정보 더 자세히 로깅
- if (err instanceof Error) {
- console.error("에러 메시지:", err.message);
- console.error("에러 스택:", err.stack);
-
- if ('code' in err) {
- console.error("SQL 에러 코드:", (err as any).code);
- }
- }
-
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: [`rfqs-po`],
- }
- )();
-}
-
-// RFQ 디테일 데이터를 가져오는 함수
-export async function getRfqDetails(rfqId: number) {
- return unstable_cache(
- async () => {
- try {
- unstable_noStore();
-
- // SQL 쿼리 직접 실행
- const data = await db
- .select()
- .from(procurementRfqDetailsView)
- .where(eq(procurementRfqDetailsView.rfqId, rfqId))
-
- console.log(`RFQ 디테일 SQL 조회 완료: ${rfqId}, ${data?.length}건`);
-
- return { data };
- } catch (err) {
- console.error("RFQ 디테일 SQL 조회 오류:", err);
-
- if (err instanceof Error) {
- console.error("에러 메시지:", err.message);
- console.error("에러 스택:", err.stack);
- }
-
- return { data: [] };
- }
- },
- [`rfq-details-sql-${rfqId}`],
- {
- revalidate: 60,
- tags: [`rfq-details-${rfqId}`],
- }
- )();
-}
-
-// RFQ ID로 디테일 데이터를 가져오는 서버 액션
-export async function fetchRfqDetails(rfqId: number) {
- "use server";
-
- try {
- const result = await getRfqDetails(rfqId);
- return result;
- } catch (error) {
- console.error("RFQ 디테일 서버 액션 오류:", error);
- return { data: [] };
- }
-}
-
-// RFQ ID로 PR 상세 항목들을 가져오는 함수
-export async function getPrItemsByRfqId(rfqId: number) {
- return unstable_cache(
- async () => {
- try {
- unstable_noStore();
-
-
- const data = await db
- .select()
- .from(prItemsView)
- .where(eq(prItemsView.procurementRfqsId, rfqId))
-
-
- console.log(`PR 항목 조회 완료: ${rfqId}, ${data.length}건`);
-
- return { data };
- } catch (err) {
- console.error("PR 항목 조회 오류:", err);
-
- if (err instanceof Error) {
- console.error("에러 메시지:", err.message);
- console.error("에러 스택:", err.stack);
- }
-
- return { data: [] };
- }
- },
- [`pr-items-${rfqId}`],
- {
- revalidate: 60, // 1분 캐시
- tags: [`pr-items-${rfqId}`],
- }
- )();
-}
-
-// 서버 액션으로 노출할 함수
-export async function fetchPrItemsByRfqId(rfqId: number) {
- "use server";
-
- try {
- const result = await getPrItemsByRfqId(rfqId);
- return result;
- } catch (error) {
- console.error("PR 항목 서버 액션 오류:", error);
- return { data: [] };
- }
-}
-
-export async function addVendorToRfq(formData: FormData) {
- try {
- // 현재 사용자 정보 가져오기
- const { userId, user } = await getAuthenticatedUser();
- console.log("userId", userId);
- // rfqId 가져오기
- const rfqId = Number(formData.get("rfqId"))
-
- if (!rfqId) {
- return {
- success: false,
- message: "RFQ ID가 필요합니다",
- }
- }
-
- // 폼 데이터 추출 및 기본 검증 (기존과 동일)
- const vendorId = Number(formData.get("vendorId"))
- const currency = formData.get("currency") as string
- const paymentTermsCode = formData.get("paymentTermsCode") as string
- const incotermsCode = formData.get("incotermsCode") as string
- const incotermsDetail = formData.get("incotermsDetail") as string || null
- const deliveryDate = formData.get("deliveryDate") ? new Date(formData.get("deliveryDate") as string) : null
- const taxCode = formData.get("taxCode") as string || null
- const placeOfShipping = formData.get("placeOfShipping") as string || null
- const placeOfDestination = formData.get("placeOfDestination") as string || null
- const materialPriceRelatedYn = formData.get("materialPriceRelatedYn") === "true"
-
- if (!vendorId || !currency || !paymentTermsCode || !incotermsCode) {
- return {
- success: false,
- message: "필수 항목이 누락되었습니다",
- }
- }
-
- // 트랜잭션 시작
- return await db.transaction(async (tx) => {
- // 0. 먼저 RFQ 상태 확인
- const rfq = await tx.query.procurementRfqs.findFirst({
- where: eq(procurementRfqs.id, rfqId),
- columns: {
- id: true,
- status: true
- }
- });
-
- if (!rfq) {
- throw new Error("RFQ를 찾을 수 없습니다");
- }
- console.log("rfq.status", rfq.status);
- // 1. RFQ 상세 정보 저장
- const insertedDetails = await tx.insert(procurementRfqDetails).values({
- procurementRfqsId: rfqId,
- vendorsId: vendorId,
- currency,
- paymentTermsCode,
- incotermsCode,
- incotermsDetail,
- deliveryDate: deliveryDate || new Date(), // null이면 현재 날짜 사용
- taxCode,
- placeOfShipping,
- placeOfDestination,
- materialPriceRelatedYn,
- updatedBy: Number(userId),
- updatedAt: new Date(),
- }).returning({ id: procurementRfqDetails.id });
-
- if (!insertedDetails || insertedDetails.length === 0) {
- throw new Error("RFQ 상세 정보 저장에 실패했습니다");
- }
-
- const detailId = insertedDetails[0].id;
-
-
-
- // 2. RFQ 상태가 "RFQ Created"인 경우 "RFQ Vendor Assignned"로 업데이트
- let statusUpdated = false;
- if (rfq.status === "RFQ Created") {
- console.log("rfq 상태 업데이트 시작")
- await tx.update(procurementRfqs)
- .set({
- status: "RFQ Vendor Assignned",
- updatedBy: Number(userId),
- updatedAt: new Date()
- })
- .where(eq(procurementRfqs.id, rfqId));
-
- statusUpdated = true;
- }
-
- // 3. 첨부 파일 처리
- const filePromises = [];
- const uploadDir = join(process.cwd(), "public", "rfq", rfqId.toString(), "vendors", detailId.toString());
-
- // 업로드 디렉토리 생성
- try {
- await mkdir(uploadDir, { recursive: true });
- } catch (error) {
- console.error("디렉토리 생성 오류:", error);
- }
-
- // FormData에서 file 타입 항목 찾기
- for (const [key, value] of formData.entries()) {
- if (key.startsWith("attachment-") && value instanceof File) {
- const file = value as File;
-
- // 파일 크기가 0이면 건너뛰기
- if (file.size === 0) continue;
-
- // 파일 이름 생성
- const uniqueId = uuidv4();
- const fileName = `${uniqueId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
- const filePath = join(uploadDir, fileName);
-
- // 파일을 버퍼로 변환
- const buffer = Buffer.from(await file.arrayBuffer());
-
- // 파일 저장
- await writeFile(filePath, buffer);
-
- // DB에 첨부 파일 정보 저장
- filePromises.push(
- tx.insert(procurementAttachments).values({
- attachmentType: 'VENDOR_SPECIFIC',
- procurementRfqsId: null,
- procurementRfqDetailsId: detailId,
- fileName: fileName,
- originalFileName: file.name,
- filePath: `/uploads/rfq/${rfqId}/vendors/${detailId}/${fileName}`,
- fileSize: file.size,
- fileType: file.type,
- description: `${file.name} - 벤더 ID ${vendorId}용 첨부파일`,
- createdBy: Number(userId),
- createdAt: new Date(),
- })
- );
- }
- }
-
- // 첨부 파일이 있으면 처리
- if (filePromises.length > 0) {
- await Promise.all(filePromises);
- }
-
- // 캐시 무효화 (여러 경로 지정 가능)
- revalidateTag(`rfq-details-${rfqId}`);
- revalidateTag(`rfqs-po`);
-
- return {
- success: true,
- message: "벤더 정보가 성공적으로 추가되었습니다",
- data: {
- id: detailId,
- statusUpdated: statusUpdated
- },
- };
- });
-
- } catch (error) {
- console.error("벤더 추가 오류:", error);
- return {
- success: false,
- message: "벤더 추가 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)),
- };
- }
-}
-
-
-// 벤더 데이터 조회 서버 액션
-export async function fetchVendors() {
- try {
- const data = await db.select().from(vendors)
-
- return {
- success: true,
- data,
- }
- } catch (error) {
- console.error("벤더 데이터 로드 오류:", error)
- return {
- success: false,
- message: "벤더 데이터를 불러오는 데 실패했습니다",
- data: []
- }
- }
-}
-
-// 통화 데이터 조회 서버 액션
-export async function fetchCurrencies() {
- try {
- // 통화 테이블이 별도로 없다면 여기서 하드코딩하거나 설정 파일에서 가져올 수도 있습니다
- const data = [
- { code: "KRW", name: "Korean Won" },
- { code: "USD", name: "US Dollar" },
- { code: "EUR", name: "Euro" },
- { code: "JPY", name: "Japanese Yen" },
- { code: "CNY", name: "Chinese Yuan" },
- ]
-
- return {
- success: true,
- data,
- }
- } catch (error) {
- console.error("통화 데이터 로드 오류:", error)
- return {
- success: false,
- message: "통화 데이터를 불러오는 데 실패했습니다",
- data: []
- }
- }
-}
-
-// 지불 조건 데이터 조회 서버 액션
-export async function fetchPaymentTerms() {
- try {
- const data = await db.select().from(paymentTerms)
-
- return {
- success: true,
- data,
- }
- } catch (error) {
- console.error("지불 조건 데이터 로드 오류:", error)
- return {
- success: false,
- message: "지불 조건 데이터를 불러오는 데 실패했습니다",
- data: []
- }
- }
-}
-
-// 인코텀즈 데이터 조회 서버 액션
-export async function fetchIncoterms() {
- try {
- const data = await db.select().from(incoterms)
-
- return {
- success: true,
- data,
- }
- } catch (error) {
- console.error("인코텀즈 데이터 로드 오류:", error)
- return {
- success: false,
- message: "인코텀즈 데이터를 불러오는 데 실패했습니다",
- data: []
- }
- }
-}
-
-export async function deleteRfqDetail(detailId: number) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- };
- }
-
- // DB에서 항목 삭제
- await db.delete(procurementRfqDetails)
- .where(eq(procurementRfqDetails.id, detailId));
-
- // 캐시 무효화
- revalidateTag(`rfq-details-${detailId}`);
-
- return {
- success: true,
- message: "RFQ 벤더 정보가 삭제되었습니다",
- };
- } catch (error) {
- console.error("RFQ 벤더 정보 삭제 오류:", error);
- return {
- success: false,
- message: "RFQ 벤더 정보 삭제 중 오류가 발생했습니다",
- };
- }
-}
-
-// RFQ 상세 정보 수정
-export async function updateRfqDetail(detailId: number, data: any) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- };
- }
-
- const userId = Number(session.user.id);
-
- // 필요한 데이터 추출
- const {
- vendorId,
- currency,
- paymentTermsCode,
- incotermsCode,
- incotermsDetail,
- deliveryDate,
- taxCode,
- placeOfShipping,
- placeOfDestination,
- materialPriceRelatedYn,
- } = data;
-
- // DB 업데이트
- await db.update(procurementRfqDetails)
- .set({
- vendorsId: Number(vendorId),
- currency,
- paymentTermsCode,
- incotermsCode,
- incotermsDetail: incotermsDetail || null,
- deliveryDate: deliveryDate ? new Date(deliveryDate) : new Date(),
- taxCode: taxCode || null,
- placeOfShipping: placeOfShipping || null,
- placeOfDestination: placeOfDestination || null,
- materialPriceRelatedYn,
- updatedBy: userId,
- updatedAt: new Date(),
- })
- .where(eq(procurementRfqDetails.id, detailId));
-
- // 캐시 무효화
- revalidateTag(`rfq-details-${detailId}`);
-
- return {
- success: true,
- message: "RFQ 벤더 정보가 수정되었습니다",
- };
- } catch (error) {
- console.error("RFQ 벤더 정보 수정 오류:", error);
- return {
- success: false,
- message: "RFQ 벤더 정보 수정 중 오류가 발생했습니다",
- };
- }
-}
-
-export async function updateRfqRemark(rfqId: number, remark: string) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- };
- }
-
- console.log(rfqId, remark)
-
- // DB 업데이트
- await db.update(procurementRfqs)
- .set({
- remark,
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(procurementRfqs.id, rfqId));
-
- // 캐시 무효화
- revalidateTag(`rfqs-po`);
- revalidatePath("/evcp/po-rfq"); // 경로도 함께 무효화
-
- return {
- success: true,
- message: "비고가 업데이트되었습니다",
- };
- } catch (error) {
- console.error("비고 업데이트 오류:", error);
- return {
- success: false,
- message: "비고 업데이트 중 오류가 발생했습니다",
- };
- }
-}
-
-export async function sealRfq(rfqId: number) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- };
- }
-
- // DB 업데이트
- await db.update(procurementRfqs)
- .set({
- rfqSealedYn: true,
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(procurementRfqs.id, rfqId));
-
- // 캐시 무효화
- revalidateTag(`rfqs-po`);
-
- return {
- success: true,
- message: "RFQ가 성공적으로 밀봉되었습니다",
- };
- } catch (error) {
- console.error("RFQ 밀봉 오류:", error);
- return {
- success: false,
- message: "RFQ 밀봉 중 오류가 발생했습니다",
- };
- }
-}
-
-// RFQ 전송 서버 액션
-export async function sendRfq(rfqId: number) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- }
- }
-
- // 현재 RFQ 상태 확인
- // RFQ 및 관련 정보 조회
- const rfq = await db.query.procurementRfqs.findFirst({
- where: eq(procurementRfqs.id, rfqId),
- columns: {
- id: true,
- rfqCode: true,
- status: true,
- dueDate: true,
- rfqSendDate: true,
- remark: true,
- rfqSealedYn: true,
- itemCode: true,
- itemName: true,
- },
- with: {
- project: {
- columns: {
- id: true,
- code: true,
- name: true,
- }
- },
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- },
- prItems: {
- columns: {
- id: true,
- rfqItem: true, // 아이템 번호
- materialCode: true,
- materialDescription: true,
- quantity: true,
- uom: true,
- prNo: true,
- majorYn: true,
- }
- }
- }
- });
-
- if (!rfq) {
- return {
- success: false,
- message: "RFQ를 찾을 수 없습니다",
- }
- }
-
- if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
- return {
- success: false,
- message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
- }
- }
-
- const isResend = rfq.status === "RFQ Sent";
-
- // 현재 사용자 정보 조회 (CC 용)
- const sender = await db.query.users.findFirst({
- where: eq(users.id, Number(session.user.id)),
- columns: {
- id: true,
- email: true,
- name: true,
- }
- });
-
- if (!sender || !sender.email) {
- return {
- success: false,
- message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
- }
- }
-
- // RFQ에 할당된 벤더 목록 조회
- const rfqDetails = await db.query.procurementRfqDetails.findMany({
- where: eq(procurementRfqDetails.procurementRfqsId, rfqId),
- columns: {
- id: true,
- vendorsId: true,
- currency: true,
- paymentTermsCode: true,
- incotermsCode: true,
- incotermsDetail: true,
- deliveryDate: true,
- },
- with: {
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- },
- paymentTerms: {
- columns: {
- code: true,
- description: true,
- }
- },
- incoterms: {
- columns: {
- code: true,
- description: true,
- }
- }
- }
- });
-
- if (rfqDetails.length === 0) {
- return {
- success: false,
- message: "할당된 벤더가 없습니다",
- }
- }
-
- // 트랜잭션 시작
- await db.transaction(async (tx) => {
- // 1. RFQ 상태 업데이트
- await tx.update(procurementRfqs)
- .set({
- status: "RFQ Sent",
- rfqSendDate: new Date(),
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(procurementRfqs.id, rfqId));
-
- // 2. 각 벤더에 대해 초기 견적서 레코드 생성 및 이메일 발송
- for (const detail of rfqDetails) {
- if (!detail.vendorsId || !detail.vendor) continue;
-
- // 기존 Draft 견적서가 있는지 확인
- const existingQuotation = await tx.query.procurementVendorQuotations.findFirst({
- where: and(
- eq(procurementVendorQuotations.rfqId, rfqId),
- eq(procurementVendorQuotations.vendorId, detail.vendorsId)
- ),
- orderBy: [desc(procurementVendorQuotations.quotationVersion)]
- });
-
- // 견적서 코드 (기존 것 재사용 또는 신규 생성)
- const quotationCode = existingQuotation?.quotationCode || `${rfq.rfqCode}-${detail.vendorsId}`;
-
- // 버전 관리 - 재전송인 경우 버전 증가
- const quotationVersion = existingQuotation ? ((existingQuotation.quotationVersion? existingQuotation.quotationVersion: 0 )+ 1) : 1;
-
- // 견적서 레코드 생성
- const insertedQuotation = await tx.insert(procurementVendorQuotations).values({
- rfqId,
- vendorId: detail.vendorsId,
- quotationCode,
- quotationVersion,
- totalItemsCount: rfq.prItems.length,
- subTotal: "0",
- taxTotal: "0",
- discountTotal: "0",
- totalPrice: "0",
- currency: detail.currency || "USD",
- // 납품일은 RFQ 납품일보다 조금 이전으로 설정 (기본값)
- estimatedDeliveryDate: detail.deliveryDate ?
- new Date(detail.deliveryDate.getTime() - 7 * 24 * 60 * 60 * 1000) : // 1주일 전
- undefined,
- paymentTermsCode: detail.paymentTermsCode,
- incotermsCode: detail.incotermsCode,
- incotermsDetail: detail.incotermsDetail,
- status: "Draft",
- createdBy: Number(session.user.id),
- updatedBy: Number(session.user.id),
- createdAt: new Date(),
- updatedAt: new Date(),
- }).returning({ id: procurementVendorQuotations.id });
-
- // 새로 생성된 견적서 ID
- const quotationId = insertedQuotation[0].id;
-
- // 3. 각 PR 아이템에 대해 견적 아이템 생성
- for (const prItem of rfq.prItems) {
- // procurementQuotationItems에 레코드 생성
- await tx.insert(procurementQuotationItems).values({
- quotationId,
- prItemId: prItem.id,
- materialCode: prItem.materialCode,
- materialDescription: prItem.materialDescription,
- quantity: prItem.quantity,
- uom: prItem.uom,
- // 기본값으로 설정된 필드
- unitPrice: 0,
- totalPrice: 0,
- currency: detail.currency || "USD",
- // 나머지 필드는 null 또는 기본값 사용
- createdAt: new Date(),
- updatedAt: new Date(),
- });
- }
-
- // 벤더에 속한 모든 사용자 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, detail.vendorsId),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- // 유효한 이메일 주소만 필터링
- const vendorEmailsString = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (vendorEmailsString) {
- // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값)
- const language = vendorUsers[0]?.language || "en";
-
- // 이메일 컨텍스트 구성
- const emailContext = {
- language: language,
- rfq: {
- id: rfq.id,
- code: rfq.rfqCode,
- title: rfq.item?.itemName || '',
- projectCode: rfq.project?.code || '',
- projectName: rfq.project?.name || '',
- description: rfq.remark || '',
- dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
- deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate, "KR") : 'N/A',
- },
- vendor: {
- id: detail.vendor.id,
- code: detail.vendor.vendorCode || '',
- name: detail.vendor.vendorName,
- },
- sender: {
- fullName: sender.name || '',
- email: sender.email,
- },
- items: rfq.prItems.map(item => ({
- itemNumber: item.rfqItem || '',
- materialCode: item.materialCode || '',
- description: item.materialDescription || '',
- quantity: item.quantity,
- uom: item.uom || '',
- })),
- details: {
- currency: detail.currency || 'USD',
- paymentTerms: detail.paymentTerms?.description || detail.paymentTermsCode || 'N/A',
- incoterms: detail.incoterms ?
- `${detail.incoterms.code} ${detail.incotermsDetail || ''}` :
- detail.incotermsCode ? `${detail.incotermsCode} ${detail.incotermsDetail || ''}` : 'N/A',
- },
- quotationCode: existingQuotation?.quotationCode || `QUO-${rfqId}-${detail.vendorsId}`,
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com',
- isResend: isResend,
- quotationVersion: quotationVersion,
- versionInfo: isResend ? `(버전 ${quotationVersion})` : '',
- };
-
- // 이메일 전송 (모든 벤더 이메일을 to 필드에 배열로 전달)
- await sendEmail({
- to: vendorEmailsString,
- subject: isResend
- ? `[RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}`
- : `[RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`,
- template: 'rfq-notification',
- context: emailContext,
- cc: sender.email, // 발신자를 CC에 추가
- });
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag(`rfqs-po`);
-
- return {
- success: true,
- message: "RFQ가 성공적으로 전송되었습니다",
- }
- } catch (error) {
- console.error("RFQ 전송 오류:", error);
- return {
- success: false,
- message: "RFQ 전송 중 오류가 발생했습니다",
- }
- }
-}
-/**
- * 첨부파일 타입 정의
- */
-export interface Attachment {
- id: number
- fileName: string
- fileSize: number
- fileType: string | null // <- null 허용
- filePath: string
- uploadedAt: Date
-}
-
-/**
- * 코멘트 타입 정의
- */
-export interface Comment {
- id: number
- rfqId: number
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string
- isVendorComment: boolean | null // null 허용으로 변경
- createdAt: Date
- updatedAt: Date
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[]
- isRead: boolean | null // null 허용으로 변경
-}
-
-
-/**
- * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- * @returns 코멘트 목록
- */
-export async function fetchVendorComments(rfqId: number, vendorId?: number): Promise<Comment[]> {
- if (!vendorId) {
- return []
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 코멘트 쿼리
- const comments = await db.query.procurementRfqComments.findMany({
- where: and(
- eq(procurementRfqComments.rfqId, rfqId),
- eq(procurementRfqComments.vendorId, vendorId)
- ),
- orderBy: [procurementRfqComments.createdAt],
- with: {
- user: {
- columns: {
- name: true
- }
- },
- vendor: {
- columns: {
- vendorName: true
- }
- },
- attachments: true,
- }
- })
-
- // 결과 매핑
- return comments.map(comment => ({
- id: comment.id,
- rfqId: comment.rfqId,
- vendorId: comment.vendorId,
- userId: comment.userId || undefined,
- content: comment.content,
- isVendorComment: comment.isVendorComment,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- userName: comment.user?.name,
- vendorName: comment.vendor?.vendorName,
- isRead: comment.isRead,
- attachments: comment.attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- fileType: att.fileType,
- filePath: att.filePath,
- uploadedAt: att.uploadedAt
- }))
- }))
- } catch (error) {
- console.error('벤더 코멘트 가져오기 오류:', error)
- throw error
- }
-}
-
-/**
- * 코멘트를 읽음 상태로 표시하는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- */
-export async function markMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
- if (!vendorId) {
- return
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 벤더가 작성한 읽지 않은 코멘트 업데이트
- await db.update(procurementRfqComments)
- .set({ isRead: true })
- .where(
- and(
- eq(procurementRfqComments.rfqId, rfqId),
- eq(procurementRfqComments.vendorId, vendorId),
- eq(procurementRfqComments.isVendorComment, true),
- eq(procurementRfqComments.isRead, false)
- )
- )
-
- // 캐시 무효화
- revalidateTag(`rfq-${rfqId}-comments`)
- } catch (error) {
- console.error('메시지 읽음 표시 오류:', error)
- throw error
- }
-}
-
-/**
- * 읽지 않은 메시지 개수 가져오기 서버 액션
- *
- * @param rfqId RFQ ID
- */
-export async function fetchUnreadMessages(rfqId: number): Promise<Record<number, number>> {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- throw new Error("인증이 필요합니다");
- }
-
- // 쿼리 빌더 방식으로 카운트 조회 - 타입 안전 방식
- const result = await db
- .select({
- vendorId: procurementRfqComments.vendorId,
- unreadCount: count()
- })
- .from(procurementRfqComments)
- .where(
- and(
- eq(procurementRfqComments.rfqId, rfqId),
- eq(procurementRfqComments.isVendorComment, true),
- eq(procurementRfqComments.isRead, false)
- )
- )
- .groupBy(procurementRfqComments.vendorId);
-
- // 결과 매핑
- const unreadMessages: Record<number, number> = {};
- result.forEach(row => {
- if (row.vendorId) {
- unreadMessages[row.vendorId] = Number(row.unreadCount);
- }
- });
-
- return unreadMessages;
- } catch (error) {
- console.error('읽지 않은 메시지 개수 가져오기 오류:', error);
- throw error;
- }
-}
-
-
-/**
- * 견적서 업데이트 서버 액션
- */
-export async function updateVendorQuotation(data: {
- id: number
- quotationVersion?: number
- currency?: string
- validUntil?: Date
- estimatedDeliveryDate?: Date
- paymentTermsCode?: string
- incotermsCode?: string
- incotermsDetail?: string
- remark?: string
- subTotal?: string
- taxTotal?: string
- discountTotal?: string
- totalPrice?: string
- totalItemsCount?: number
-}) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- }
- }
-
- // 견적서 존재 확인
- const quotation = await db.query.procurementVendorQuotations.findFirst({
- where: eq(procurementVendorQuotations.id, data.id),
- })
-
- if (!quotation) {
- return {
- success: false,
- message: "견적서를 찾을 수 없습니다",
- }
- }
-
- // 권한 확인 (벤더 또는 관리자만 수정 가능)
- const isAuthorized =
- (session.user.domain === "partners" && session.user.companyId === quotation.vendorId)
-
- if (!isAuthorized) {
- return {
- success: false,
- message: "견적서 수정 권한이 없습니다",
- }
- }
-
- // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능)
- if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
- return {
- success: false,
- message: "제출되었거나 승인된 견적서는 수정할 수 없습니다",
- }
- }
-
- // 업데이트할 데이터 구성
- const updateData: Record<string, any> = {
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- }
-
- // 필드 추가
- if (data.currency) updateData.currency = data.currency
- if (data.validUntil) updateData.validUntil = data.validUntil
- if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate
- if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode
- if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode
- if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail
- if (data.remark !== undefined) updateData.remark = data.remark
- if (data.subTotal) updateData.subTotal = data.subTotal
- if (data.taxTotal) updateData.taxTotal = data.taxTotal
- if (data.discountTotal) updateData.discountTotal = data.discountTotal
- if (data.totalPrice) updateData.totalPrice = data.totalPrice
- if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount
-
- // Rejected 상태에서 수정 시 Draft 상태로 변경
- if (quotation.status === "Rejected") {
- updateData.status = "Draft"
-
- // 버전 증가
- if (data.quotationVersion) {
- updateData.quotationVersion = data.quotationVersion + 1
- } else {
- updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1
- }
- }
-
- // 견적서 업데이트
- await db.update(procurementVendorQuotations)
- .set(updateData)
- .where(eq(procurementVendorQuotations.id, data.id))
-
- // 캐시 무효화
- revalidateTag(`quotation-${data.id}`)
- revalidateTag(`rfq-${quotation.rfqId}`)
-
- return {
- success: true,
- message: "견적서가 업데이트되었습니다",
- }
- } catch (error) {
- console.error("견적서 업데이트 오류:", error)
- return {
- success: false,
- message: "견적서 업데이트 중 오류가 발생했습니다",
- }
- }
-}
-
-interface QuotationItem {
- unitPrice: number;
- deliveryDate: Date | null;
- status: "Draft" | "Rejected" | "Submitted" | "Approved"; // 상태를 유니온 타입으로 정의
-
- // 필요한 다른 속성들도 추가
-}
-
-/**
- * 견적서 제출 서버 액션
- */
-export async function submitVendorQuotation(data: {
- id: number
- quotationVersion?: number
- currency?: string
- validUntil?: Date
- estimatedDeliveryDate?: Date
- paymentTermsCode?: string
- incotermsCode?: string
- incotermsDetail?: string
- remark?: string
- subTotal?: string
- taxTotal?: string
- discountTotal?: string
- totalPrice?: string
- totalItemsCount?: number
-}) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- }
- }
-
- // 견적서 존재 확인
- const quotation = await db.query.procurementVendorQuotations.findFirst({
- where: eq(procurementVendorQuotations.id, data.id),
- with: {
- items: true,
- }
- })
-
- if (!quotation) {
- return {
- success: false,
- message: "견적서를 찾을 수 없습니다",
- }
- }
-
- // 권한 확인 (벤더 또는 관리자만 제출 가능)
- const isAuthorized =
- (session.user.domain === "partners" && session.user.companyId === quotation.vendorId)
-
- if (!isAuthorized) {
- return {
- success: false,
- message: "견적서 제출 권한이 없습니다",
- }
- }
-
- // 상태 확인 (Draft 또는 Rejected 상태만 제출 가능)
- if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
- return {
- success: false,
- message: "이미 제출되었거나 승인된 견적서는 다시 제출할 수 없습니다",
- }
- }
-
- // 견적 항목 검증
- if (!quotation.items || (quotation.items as QuotationItem[]).length === 0) {
- return {
- success: false,
- message: "견적 항목이 없습니다",
- }
- }
-
- // 필수 항목 검증
- const hasEmptyItems = (quotation.items as QuotationItem[]).some(item =>
- item.unitPrice <= 0 || !item.deliveryDate
- )
-
- if (hasEmptyItems) {
- return {
- success: false,
- message: "모든 항목의 단가와 납품일을 입력해주세요",
- }
- }
-
- // 필수 정보 검증
- if (!data.validUntil || !data.estimatedDeliveryDate) {
- return {
- success: false,
- message: "견적 유효기간과 예상 납품일은 필수 항목입니다",
- }
- }
-
- // 업데이트할 데이터 구성
- const updateData: Record<string, any> = {
- status: "Submitted",
- submittedAt: new Date(),
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- }
-
- // 필드 추가
- if (data.currency) updateData.currency = data.currency
- if (data.validUntil) updateData.validUntil = data.validUntil
- if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate
- if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode
- if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode
- if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail
- if (data.remark !== undefined) updateData.remark = data.remark
- if (data.subTotal) updateData.subTotal = data.subTotal
- if (data.taxTotal) updateData.taxTotal = data.taxTotal
- if (data.discountTotal) updateData.discountTotal = data.discountTotal
- if (data.totalPrice) updateData.totalPrice = data.totalPrice
- if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount
-
- // Rejected 상태에서 제출 시 버전 증가
- if (quotation.status === "Rejected") {
- updateData.status = "Revised"
-
- if (data.quotationVersion) {
- updateData.quotationVersion = data.quotationVersion + 1
- } else {
- updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1
- }
- }
-
- // 견적서 업데이트
- await db.update(procurementVendorQuotations)
- .set(updateData)
- .where(eq(procurementVendorQuotations.id, data.id))
-
- // 캐시 무효화
- revalidateTag(`quotation-${data.id}`)
- revalidateTag(`rfq-${quotation.rfqId}`)
-
- return {
- success: true,
- message: "견적서가 성공적으로 제출되었습니다",
- }
- } catch (error) {
- console.error("견적서 제출 오류:", error)
- return {
- success: false,
- message: "견적서 제출 중 오류가 발생했습니다",
- }
- }
-}
-
-/**
- * 견적 항목 업데이트 서버 액션
- */
-export async function updateQuotationItem(data: {
- id: number
- unitPrice?: number
- totalPrice?: number
- vendorMaterialCode?: string
- vendorMaterialDescription?: string
- deliveryDate?: Date | null
- leadTimeInDays?: number
- taxRate?: number
- taxAmount?: number
- discountRate?: number
- discountAmount?: number
- remark?: string
- isAlternative?: boolean
- isRecommended?: boolean
-}) {
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- }
- }
-
- // 항목 존재 확인
- const item = await db.query.procurementQuotationItems.findFirst({
- where: eq(procurementQuotationItems.id, data.id),
- with: {
- quotation: true,
- }
- })
-
- if (!item || !item.quotation) {
- return {
- success: false,
- message: "견적 항목을 찾을 수 없습니다",
- }
- }
-
- // 권한 확인 (벤더 또는 관리자만 수정 가능)
- const isAuthorized = (
- session.user.domain === "partners" &&
- session.user.companyId === (item.quotation as { vendorId: number }).vendorId
- )
-
- if (!isAuthorized) {
- return {
- success: false,
- message: "견적 항목 수정 권한이 없습니다",
- }
- }
-
- const quotation = item.quotation as Quotation;
-
- // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능)
- if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
- return {
- success: false,
- message: "제출되었거나 승인된 견적서의 항목은 수정할 수 없습니다",
- }
- }
-
- // 업데이트할 데이터 구성
- const updateData: Record<string, any> = {
- updatedAt: new Date(),
- }
-
- // 필드 추가
- if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice
- if (data.totalPrice !== undefined) updateData.totalPrice = data.totalPrice
- if (data.vendorMaterialCode !== undefined) updateData.vendorMaterialCode = data.vendorMaterialCode
- if (data.vendorMaterialDescription !== undefined) updateData.vendorMaterialDescription = data.vendorMaterialDescription
- if (data.deliveryDate !== undefined) updateData.deliveryDate = data.deliveryDate
- if (data.leadTimeInDays !== undefined) updateData.leadTimeInDays = data.leadTimeInDays
- if (data.taxRate !== undefined) updateData.taxRate = data.taxRate
- if (data.taxAmount !== undefined) updateData.taxAmount = data.taxAmount
- if (data.discountRate !== undefined) updateData.discountRate = data.discountRate
- if (data.discountAmount !== undefined) updateData.discountAmount = data.discountAmount
- if (data.remark !== undefined) updateData.remark = data.remark
- if (data.isAlternative !== undefined) updateData.isAlternative = data.isAlternative
- if (data.isRecommended !== undefined) updateData.isRecommended = data.isRecommended
-
- // 항목 업데이트
- await db.update(procurementQuotationItems)
- .set(updateData)
- .where(eq(procurementQuotationItems.id, data.id))
-
- // 캐시 무효화
- revalidateTag(`quotation-${item.quotationId}`)
-
- return {
- success: true,
- message: "견적 항목이 업데이트되었습니다",
- }
- } catch (error) {
- console.error("견적 항목 업데이트 오류:", error)
- return {
- success: false,
- message: "견적 항목 업데이트 중 오류가 발생했습니다",
- }
- }
-}
-
-
-// Quotation 상태 타입 정의
-export type QuotationStatus = "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted";
-
-// 인터페이스 정의
-export interface Quotation {
- id: number;
- quotationCode: string;
- status: QuotationStatus;
- totalPrice: string;
- currency: string;
- submittedAt: string | null;
- validUntil: string | null;
- vendorId: number;
- rfq?: {
- rfqCode: string;
- } | null;
- vendor?: any;
-}
-
-
-/**
- * 벤더별 견적서 목록 조회
- */
-export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: string) {
- return unstable_cache(
- async () => {
- try {
- // 페이지네이션 설정
- const page = input.page || 1;
- const perPage = input.perPage || 10;
- const offset = (page - 1) * perPage;
-
- // 필터링 설정
- // advancedTable 모드로 where 절 구성
- const advancedWhere = filterColumns({
- table: procurementVendorQuotations,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 글로벌 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(procurementVendorQuotations.quotationCode, s),
- ilike(procurementVendorQuotations.status, s),
- ilike(procurementVendorQuotations.totalPrice, s)
- );
- }
-
- // 벤더 ID 조건
- const vendorIdWhere = vendorId ?
- eq(procurementVendorQuotations.vendorId, Number(vendorId)) :
- undefined;
-
- // 모든 조건 결합
- let whereConditions = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (vendorIdWhere) whereConditions.push(vendorIdWhere);
-
- // 최종 조건
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
-
- // 정렬 설정
- const orderBy = input.sort && input.sort.length > 0
- ? input.sort.map((item) => {
- // @ts-ignore - 동적 속성 접근
- return item.desc ? desc(procurementVendorQuotations[item.id]) : asc(procurementVendorQuotations[item.id]);
- })
- : [asc(procurementVendorQuotations.updatedAt)];
-
- // 쿼리 실행
- const quotations = await db.query.procurementVendorQuotations.findMany({
- where: finalWhere,
- orderBy,
- offset,
- limit: perPage,
- with: {
- rfq:true,
- vendor: true,
- }
- });
- // 전체 개수 조회
- const { totalCount } = await db
- .select({ totalCount: count() })
- .from(procurementVendorQuotations)
- .where(finalWhere || undefined)
- .then(rows => rows[0]);
-
-
- // 페이지 수 계산
- const pageCount = Math.ceil(Number(totalCount) / perPage);
-
- return {
- data: quotations as Quotation[],
- pageCount
- };
- } catch (err) {
- console.error("getVendorQuotations 에러:", err);
- return { data: [], pageCount: 0 };
- }
- },
- [`vendor-quotations-${vendorId}-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: [`vendor-quotations-${vendorId}`],
- }
- )();
-}
-
-/**
- * 견적서 상태별 개수 조회
- */
-export async function getQuotationStatusCounts(vendorId: string) {
- return unstable_cache(
- async () => {
- try {
- const initial: Record<QuotationStatus, number> = {
- Draft: 0,
- Submitted: 0,
- Revised: 0,
- Rejected: 0,
- Accepted: 0,
- };
-
- // 벤더 ID 조건
- const whereCondition = vendorId ?
- eq(procurementVendorQuotations.vendorId, Number(vendorId)) :
- undefined;
-
- // 상태별 그룹핑 쿼리
- const rows = await db
- .select({
- status: procurementVendorQuotations.status,
- count: count(),
- })
- .from(procurementVendorQuotations)
- .where(whereCondition)
- .groupBy(procurementVendorQuotations.status);
-
- // 결과 처리
- const result = rows.reduce<Record<QuotationStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as QuotationStatus] = Number(count);
- }
- return acc;
- }, initial);
-
- return result;
- } catch (err) {
- console.error("getQuotationStatusCounts 에러:", err);
- return {} as Record<QuotationStatus, number>;
- }
- },
- [`quotation-status-counts-${vendorId}`],
- {
- revalidate: 3600,
- }
- )();
-}
-
-
-/**
- * 벤더 입장에서 구매자와의 커뮤니케이션 메시지를 가져오는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- * @returns 코멘트 목록
- */
-export async function fetchBuyerVendorComments(rfqId: number, vendorId: number): Promise<Comment[]> {
- if (!rfqId || !vendorId) {
- return [];
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다");
- }
-
- // 벤더 접근 권한 확인 (벤더 사용자이며 해당 벤더의 ID와 일치해야 함)
- if (
- session.user.domain === "partners" &&
- ((session.user.companyId ?? 0) !== Number(vendorId))
- ) {
- throw new Error("접근 권한이 없습니다");
- }
-
- // 코멘트 쿼리
- const comments = await db.query.procurementRfqComments.findMany({
- where: and(
- eq(procurementRfqComments.rfqId, rfqId),
- eq(procurementRfqComments.vendorId, vendorId)
- ),
- orderBy: [procurementRfqComments.createdAt],
- with: {
- user: {
- columns: {
- name: true
- }
- },
- vendor: {
- columns: {
- vendorName: true
- }
- },
- attachments: true,
- }
- });
-
- // 벤더가 접근하는 경우, 벤더 메시지를 읽음 상태로 표시
- if (session.user.domain === "partners") {
- // 읽지 않은 구매자 메시지를 읽음 상태로 업데이트
- await db.update(procurementRfqComments)
- .set({ isRead: true })
- .where(
- and(
- eq(procurementRfqComments.rfqId, rfqId),
- eq(procurementRfqComments.vendorId, vendorId),
- eq(procurementRfqComments.isVendorComment, false), // 구매자가 보낸 메시지
- eq(procurementRfqComments.isRead, false)
- )
- )
- .execute();
- }
-
- // 결과 매핑
- return comments.map(comment => ({
- id: comment.id,
- rfqId: comment.rfqId,
- vendorId: comment.vendorId,
- userId: comment.userId || undefined,
- content: comment.content,
- isVendorComment: comment.isVendorComment,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- userName: comment.user?.name,
- vendorName: comment.vendor?.vendorName,
- isRead: comment.isRead,
- attachments: comment.attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- fileType: att.fileType,
- filePath: att.filePath,
- uploadedAt: att.uploadedAt
- }))
- }));
- } catch (error) {
- console.error('벤더-구매자 커뮤니케이션 가져오기 오류:', error);
- throw error;
- }
-}
-
-
-const getRandomProject = async () => {
- const allProjects = await db.select().from(projects).limit(10);
- const randomIndex = Math.floor(Math.random() * allProjects.length);
- return allProjects[randomIndex] || null;
-};
-
-const getRandomItem = async () => {
- const allItems = await db.select().from(items).limit(10);
- const randomIndex = Math.floor(Math.random() * allItems.length);
- return allItems[randomIndex] || null;
-};
-
-// 외부 시스템에서 RFQ 가져오기 서버 액션
-export async function fetchExternalRfqs() {
- try {
- // 현재 로그인한 사용자의 세션 정보 가져오기
- const session = await getServerSession(authOptions);
-
- if (!session || !session.user || !session.user.id) {
- return {
- success: false,
- message: '인증된 사용자 정보를 찾을 수 없습니다'
- };
- }
-
- const userId = session.user.id;
-
- const randomProject = await getRandomProject();
- const randomItem = await getRandomItem();
-
- if (!randomProject || !randomItem) {
- return {
- success: false,
- message: '임의 데이터를 생성하는 데 필요한 기본 데이터가 없습니다'
- };
- }
-
- // 현재 날짜 기준 임의 날짜 생성
- const today = new Date();
- const dueDate = new Date(today);
- dueDate.setDate(today.getDate() + Math.floor(Math.random() * 30) + 15); // 15-45일 후
-
-
- // RFQ 코드 생성 (현재 연도 + 3자리 숫자)
- const currentYear = today.getFullYear();
- const randomNum = Math.floor(Math.random() * 900) + 100; // 100-999
- const rfqCode = `R${currentYear}${randomNum}`;
- const seriesOptions = ["SS", "II", ""];
- const randomSeriesIndex = Math.floor(Math.random() * seriesOptions.length);
- const seriesValue = seriesOptions[randomSeriesIndex];
-
- // RFQ 생성 - 로그인한 사용자 ID 사용
- const newRfq = await db.insert(procurementRfqs).values({
- rfqCode,
- projectId: randomProject.id,
- series:seriesValue,
- itemCode: randomItem.itemCode || `ITEM-${Math.floor(Math.random() * 1000)}`, // itemId 대신 itemCode 사용
- itemName: randomItem.itemName || `임의 아이템 ${Math.floor(Math.random() * 100)}`, // itemName 추가
- dueDate,
- rfqSendDate: null, // null로 설정/
- status: "RFQ Created",
- rfqSealedYn: false,
- picCode: `PIC-${Math.floor(Math.random() * 1000)}`,
- remark: "테스트용으로 아무말이나 들어간 것으로 실제로는 SAP에 있는 값이 옵니다. 오해 ㄴㄴ",
- createdBy: userId,
- updatedBy: userId,
- }).returning();
-
- if (newRfq.length === 0) {
- return {
- success: false,
- message: 'RFQ 생성에 실패했습니다'
- };
- }
-
- // PR 항목 생성 (1-3개 임의 생성)
- const prItemsCount = Math.floor(Math.random() * 3) + 1;
- const createdPrItems = [];
-
- for (let i = 0; i < prItemsCount; i++) {
- const deliveryDate = new Date(today);
- deliveryDate.setDate(today.getDate() + Math.floor(Math.random() * 60) + 30); // 30-90일 후
-
- const randomTwoDigits = String(Math.floor(Math.random() * 100)).padStart(2, '0');
- // 프로젝트와 아이템 코드가 있다고 가정하고, 없을 경우 기본값 사용
- const projectCode = randomProject.code || 'PROJ';
- const itemCode = randomItem.itemCode || 'ITEM';
- const materialCode = `${projectCode}${itemCode}${randomTwoDigits}`;
- const isMajor = i === 0 ? true : Math.random() > 0.7;
-
- const newPrItem = await db.insert(prItems).values({
- procurementRfqsId: newRfq[0].id,
- rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`,
- prItem: `PRI-${Math.floor(Math.random() * 1000)}`,
- prNo: `PRN-${Math.floor(Math.random() * 1000)}`,
- // itemId: randomItem.id,
- materialCode,
- materialCategory: "Standard",
- acc: `ACC-${Math.floor(Math.random() * 100)}`,
- materialDescription: `${['알루미늄', '구리', '철', '실리콘'][Math.floor(Math.random() * 4)]} 재질 부품`,
- size: `${Math.floor(Math.random() * 100) + 10}x${Math.floor(Math.random() * 100) + 10}`,
- deliveryDate,
- quantity: Math.floor(Math.random() * 100) + 1,
- uom: ['EA', 'KG', 'M', 'L'][Math.floor(Math.random() * 4)],
- grossWeight: Math.floor(Math.random() * 1000) / 10,
- gwUom: ['KG', 'T'][Math.floor(Math.random() * 2)],
- specNo: `SPEC-${Math.floor(Math.random() * 1000)}`,
- majorYn:isMajor, // 30% 확률로 true
- remark: "외부 시스템에서 가져온 PR 항목",
- }).returning();
-
- createdPrItems.push(newPrItem[0]);
- }
-
- revalidateTag(`rfqs-po`)
-
- return {
- success: true,
- message: '외부 RFQ를 성공적으로 가져왔습니다',
- data: {
- rfq: newRfq[0],
- prItems: createdPrItems
- }
- };
-
- } catch (error) {
- console.error('외부 RFQ 가져오기 오류:', error);
- return {
- success: false,
- message: '외부 RFQ를 가져오는 중 오류가 발생했습니다'
- };
- }
-}
-
-/**
- * RFQ ID에 해당하는 모든 벤더 견적 정보를 조회하는 서버 액션
- * @param rfqId RFQ ID
- * @returns 견적 정보 목록
- */
-export async function fetchVendorQuotations(rfqId: number) {
- try {
- // 벤더 정보와 함께 견적 정보 조회
- const quotations = await db
- .select({
- // 견적 기본 정보
- id: procurementVendorQuotations.id,
- rfqId: procurementVendorQuotations.rfqId,
- vendorId: procurementVendorQuotations.vendorId,
- quotationCode: procurementVendorQuotations.quotationCode,
- quotationVersion: procurementVendorQuotations.quotationVersion,
- totalItemsCount: procurementVendorQuotations.totalItemsCount,
- subTotal: procurementVendorQuotations.subTotal,
- taxTotal: procurementVendorQuotations.taxTotal,
- discountTotal: procurementVendorQuotations.discountTotal,
- totalPrice: procurementVendorQuotations.totalPrice,
- currency: procurementVendorQuotations.currency,
- validUntil: procurementVendorQuotations.validUntil,
- estimatedDeliveryDate: procurementVendorQuotations.estimatedDeliveryDate,
- paymentTermsCode: procurementVendorQuotations.paymentTermsCode,
- incotermsCode: procurementVendorQuotations.incotermsCode,
- incotermsDetail: procurementVendorQuotations.incotermsDetail,
- status: procurementVendorQuotations.status,
- remark: procurementVendorQuotations.remark,
- rejectionReason: procurementVendorQuotations.rejectionReason,
- submittedAt: procurementVendorQuotations.submittedAt,
- acceptedAt: procurementVendorQuotations.acceptedAt,
- createdAt: procurementVendorQuotations.createdAt,
- updatedAt: procurementVendorQuotations.updatedAt,
-
- // 벤더 정보
- vendorName: vendors.vendorName,
- paymentTermsDescription: paymentTerms.description,
- incotermsDescription: incoterms.description,
- })
- .from(procurementVendorQuotations)
- .leftJoin(vendors, eq(procurementVendorQuotations.vendorId, vendors.id))
- .leftJoin(paymentTerms, eq(procurementVendorQuotations.paymentTermsCode, paymentTerms.code))
- .leftJoin(incoterms, eq(procurementVendorQuotations.incotermsCode, incoterms.code))
- .where(
- and(
- eq(procurementVendorQuotations.rfqId, rfqId),
- // eq(procurementVendorQuotations.status, "Submitted") // <=== Submitted 상태만!
- )
- )
- .orderBy(desc(procurementVendorQuotations.updatedAt))
-
-
- return { success: true, data: quotations }
- } catch (error) {
- console.error("벤더 견적 조회 오류:", error)
- return { success: false, error: "벤더 견적을 조회하는 중 오류가 발생했습니다" }
- }
-}
-
-/**
- * 견적 ID 목록에 해당하는 모든 견적 아이템 정보를 조회하는 서버 액션
- * @param quotationIds 견적 ID 배열
- * @returns 견적 아이템 정보 목록
- */
-export async function fetchQuotationItems(quotationIds: number[]) {
- try {
- // 빈 배열이 전달된 경우 빈 결과 반환
- if (!quotationIds.length) {
- return { success: true, data: [] }
- }
-
- // 견적 아이템 정보 조회
- const items = await db
- .select()
- .from(procurementQuotationItems)
- .where(inArray(procurementQuotationItems.quotationId, quotationIds))
- .orderBy(procurementQuotationItems.id)
-
- return { success: true, data: items }
- } catch (error) {
- console.error("견적 아이템 조회 오류:", error)
- return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" }
- }
-}
-
diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
deleted file mode 100644
index 79524f58..00000000
--- a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState } from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { toast } from "sonner"
-import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react"
-
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { ProcurementRfqsView } from "@/db/schema"
-import { addVendorToRfq } from "@/lib/procurement-rfqs/services"
-import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { cn } from "@/lib/utils"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-// 필수 필드를 위한 커스텀 레이블 컴포넌트
-const RequiredLabel = ({ children }: { children: React.ReactNode }) => (
- <FormLabel>
- {children} <span className="text-red-500">*</span>
- </FormLabel>
-);
-
-// 폼 유효성 검증 스키마
-const vendorFormSchema = z.object({
- vendorId: z.string().min(1, "벤더를 선택해주세요"),
- currency: z.string().min(1, "통화를 선택해주세요"),
- paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
- incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
- incotermsDetail: z.string().optional(),
- deliveryDate: z.string().optional(),
- taxCode: z.string().optional(),
- placeOfShipping: z.string().optional(),
- placeOfDestination: z.string().optional(),
- materialPriceRelatedYn: z.boolean().default(false),
-})
-
-type VendorFormValues = z.infer<typeof vendorFormSchema>
-
-interface AddVendorDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: ProcurementRfqsView | null
- // 벤더 및 기타 옵션 데이터를 prop으로 받음
- vendors?: { id: number; vendorName: string; vendorCode: string }[]
- currencies?: { code: string; name: string }[]
- paymentTerms?: { code: string; description: string }[]
- incoterms?: { code: string; description: string }[]
- onSuccess?: () => void
- existingVendorIds?: number[]
-
-}
-
-export function AddVendorDialog({
- open,
- onOpenChange,
- selectedRfq,
- vendors = [],
- currencies = [],
- paymentTerms = [],
- incoterms = [],
- onSuccess,
- existingVendorIds = [], // 기본값 빈 배열
-}: AddVendorDialogProps) {
-
-
- const availableVendors = React.useMemo(() => {
- return vendors.filter(vendor => !existingVendorIds.includes(vendor.id));
- }, [vendors, existingVendorIds]);
-
-
- // 파일 업로드 상태 관리
- const [attachments, setAttachments] = useState<File[]>([])
- const [isSubmitting, setIsSubmitting] = useState(false)
-
- // 벤더 선택을 위한 팝오버 상태
- const [vendorOpen, setVendorOpen] = useState(false)
-
- const form = useForm<VendorFormValues>({
- resolver: zodResolver(vendorFormSchema),
- defaultValues: {
- vendorId: "",
- currency: "",
- paymentTermsCode: "",
- incotermsCode: "",
- incotermsDetail: "",
- deliveryDate: "",
- taxCode: "",
- placeOfShipping: "",
- placeOfDestination: "",
- materialPriceRelatedYn: false,
- },
- })
-
- // 폼 제출 핸들러
- async function onSubmit(values: VendorFormValues) {
- if (!selectedRfq) {
- toast.error("선택된 RFQ가 없습니다")
- return
- }
-
- try {
- setIsSubmitting(true)
-
- // FormData 생성
- const formData = new FormData()
- formData.append("rfqId", selectedRfq.id.toString())
-
- // 폼 데이터 추가
- Object.entries(values).forEach(([key, value]) => {
- formData.append(key, value.toString())
- })
-
- // 첨부파일 추가
- attachments.forEach((file, index) => {
- formData.append(`attachment-${index}`, file)
- })
-
- // 서버 액션 호출
- const result = await addVendorToRfq(formData)
-
- if (result.success) {
- toast.success("벤더가 성공적으로 추가되었습니다")
- onOpenChange(false)
- form.reset()
- setAttachments([])
- onSuccess?.()
- } else {
- toast.error(result.message || "벤더 추가 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("벤더 추가 오류:", error)
- toast.error("벤더 추가 중 오류가 발생했습니다")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 파일 업로드 핸들러
- const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
- if (event.target.files && event.target.files.length > 0) {
- const newFiles = Array.from(event.target.files)
- setAttachments((prev) => [...prev, ...newFiles])
- }
- }
-
- // 파일 삭제 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments((prev) => prev.filter((_, i) => i !== index))
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */}
- <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}>
- {/* 고정 헤더 */}
- <div className="p-6 border-b">
- <DialogHeader>
- <DialogTitle>벤더 추가</DialogTitle>
- <DialogDescription>
- {selectedRfq ? (
- <>
- <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
- </>
- ) : (
- "RFQ에 벤더를 추가합니다."
- )}
- </DialogDescription>
- </DialogHeader>
- </div>
-
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto p-6">
- <Form {...form}>
- <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 검색 가능한 벤더 선택 필드 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <RequiredLabel>벤더</RequiredLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className={cn(
- "w-full justify-between",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value
- ? vendors.find((vendor) => String(vendor.id) === field.value)
- ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
- : "벤더를 선택하세요"
- : "벤더를 선택하세요"}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput placeholder="벤더 검색..." />
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
- <CommandList>
- <ScrollArea className="h-60">
- <CommandGroup>
- {availableVendors.length > 0 ? (
- availableVendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorName} ${vendor.vendorCode}`}
- onSelect={() => {
- form.setValue("vendorId", String(vendor.id), {
- shouldValidate: true,
- })
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- String(vendor.id) === field.value
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- {vendor.vendorName} ({vendor.vendorCode})
- </CommandItem>
- ))
- ) : (
- <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem>
- )}
- </CommandGroup>
- </ScrollArea>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <RequiredLabel>통화</RequiredLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.name} ({currency.code})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="paymentTermsCode"
- render={({ field }) => (
- <FormItem>
- <RequiredLabel>지불 조건</RequiredLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="지불 조건 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {paymentTerms.map((term) => (
- <SelectItem key={term.code} value={term.code}>
- {term.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem>
- <RequiredLabel>인코텀즈</RequiredLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {incoterms.map((incoterm) => (
- <SelectItem key={incoterm.code} value={incoterm.code}>
- {incoterm.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 나머지 필드들은 동일하게 유지 */}
- <FormField
- control={form.control}
- name="incotermsDetail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>인코텀즈 세부사항</FormLabel>
- <FormControl>
- <Input {...field} placeholder="인코텀즈 세부사항" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="deliveryDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>납품 예정일</FormLabel>
- <FormControl>
- <Input {...field} type="date" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="taxCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>세금 코드</FormLabel>
- <FormControl>
- <Input {...field} placeholder="세금 코드" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="placeOfShipping"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선적지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="선적지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="placeOfDestination"
- render={({ field }) => (
- <FormItem>
- <FormLabel>도착지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="도착지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="materialPriceRelatedYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
- <FormControl>
- <input
- type="checkbox"
- checked={field.value}
- onChange={field.onChange}
- className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel>하도급대금 연동제 여부</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- {/* 파일 업로드 섹션 */}
- <div className="space-y-2">
- <Label>첨부 파일</Label>
- <div className="border rounded-md p-4">
- <div className="flex items-center justify-center w-full">
- <label
- htmlFor="file-upload"
- className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
- >
- <div className="flex flex-col items-center justify-center pt-5 pb-6">
- <Upload className="w-8 h-8 mb-2 text-gray-500" />
- <p className="mb-2 text-sm text-gray-500">
- <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요
- </p>
- <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p>
- </div>
- <input
- id="file-upload"
- type="file"
- className="hidden"
- multiple
- onChange={handleFileUpload}
- />
- </label>
- </div>
-
- {/* 업로드된 파일 목록 */}
- {attachments.length > 0 && (
- <div className="mt-4 space-y-2">
- <h4 className="text-sm font-medium">업로드된 파일</h4>
- <ul className="space-y-2">
- {attachments.map((file, index) => (
- <li
- key={index}
- className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md"
- >
- <div className="flex items-center space-x-2">
- <File className="w-4 h-4 text-gray-500" />
- <span className="truncate max-w-[250px]">{file.name}</span>
- <span className="text-gray-500 text-xs">
- ({(file.size / 1024).toFixed(1)} KB)
- </span>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="w-4 h-4 text-gray-500" />
- </Button>
- </li>
- ))}
- </ul>
- </div>
- )}
- </div>
- </div>
- </form>
- </Form>
- </div>
-
- {/* 고정 푸터 */}
- <div className="p-6 border-t">
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- form="vendor-form"
- disabled={isSubmitting}
- >
- {isSubmitting ? "처리 중..." : "벤더 추가"}
- </Button>
- </DialogFooter>
- </div>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
deleted file mode 100644
index 49d982e1..00000000
--- a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type RfqDetailView } from "./rfq-detail-column"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
-
-
-interface DeleteRfqDetailDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- detail: RfqDetailView | null
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteRfqDetailDialog({
- detail,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteRfqDetailDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- if (!detail) return
-
- startDeleteTransition(async () => {
- try {
- const result = await deleteRfqDetail(detail.detailId)
-
- if (!result.success) {
- toast.error(result.message || "삭제 중 오류가 발생했습니다")
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("RFQ 벤더 정보가 삭제되었습니다")
- onSuccess?.()
- } catch (error) {
- console.error("RFQ 벤더 삭제 오류:", error)
- toast.error("삭제 중 오류가 발생했습니다")
- }
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
deleted file mode 100644
index bc257202..00000000
--- a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
+++ /dev/null
@@ -1,393 +0,0 @@
-"use client"
-
-import * as React from "react"
-import type { ColumnDef, Row } from "@tanstack/react-table";
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Ellipsis, MessageCircle, ExternalLink } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>;
- type: "delete" | "update" | "communicate"; // communicate 타입 추가
-}
-
-// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요)
-export interface RfqDetailView {
- detailId: number
- rfqId: number
- rfqCode: string
- vendorId?: number | null // 벤더 ID 필드 추가
- projectCode: string | null
- projectName: string | null
- vendorCountry: string | null
- itemCode: string | null
- itemName: string | null
- vendorName: string | null
- vendorCode: string | null
- currency: string | null
- paymentTermsCode: string | null
- paymentTermsDescription: string | null
- incotermsCode: string | null
- incotermsDescription: string | null
- incotermsDetail: string | null
- deliveryDate: Date | null
- taxCode: string | null
- placeOfShipping: string | null
- placeOfDestination: string | null
- materialPriceRelatedYn: boolean | null
- hasQuotation: boolean | null
- updatedByUserName: string | null
- quotationStatus: string | null
- updatedAt: Date | null
- prItemsCount: number
- majorItemsCount: number
- quotationVersion:number | null
- // 커뮤니케이션 관련 필드 추가
- commentCount?: number // 전체 코멘트 수
- unreadCount?: number // 읽지 않은 코멘트 수
- lastCommentDate?: Date // 마지막 코멘트 날짜
-}
-
-interface GetColumnsProps<TData> {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<TData> | null>
- >;
- unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수
-}
-
-export function getRfqDetailColumns({
- setRowAction,
- unreadMessages = {},
-}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
- return [
- {
- accessorKey: "quotationStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 상태" />
- ),
- cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>,
- meta: {
- excelHeader: "견적 상태"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "quotationVersion",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 버전" />
- ),
- cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>,
- meta: {
- excelHeader: "견적 버전"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
- meta: {
- excelHeader: "벤더 코드"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더명" />
- ),
- cell: ({ row }) => {
- const vendorName = row.getValue("vendorName") as string;
- const vendorId = row.original.vendorId;
-
- if (!vendorName || !vendorId) {
- return <div>{vendorName}</div>;
- }
-
- const handleVendorClick = () => {
- window.open(`/evcp/vendors/${vendorId}/info`, '_blank');
- };
-
- return (
- <Button
- variant="link"
- className="h-auto p-0 text-left justify-start font-normal text-foreground underline-offset-4 hover:underline"
- onClick={handleVendorClick}
- >
- <span className="flex items-center gap-1">
- {vendorName}
- {/* <ExternalLink className="h-3 w-3 opacity-50" /> */}
- </span>
- </Button>
- );
- },
- meta: {
- excelHeader: "벤더명"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "vendorType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="내외자" />
- ),
- cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>,
- meta: {
- excelHeader: "내외자"
- },
- enableResizing: true,
- size: 80,
- },
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="통화" />
- ),
- cell: ({ row }) => <div>{row.getValue("currency")}</div>,
- meta: {
- excelHeader: "통화"
- },
- enableResizing: true,
- size: 80,
- },
- {
- accessorKey: "paymentTermsCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>,
- meta: {
- excelHeader: "지불 조건 코드"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "paymentTermsDescription",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="지불 조건" />
- ),
- cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>,
- meta: {
- excelHeader: "지불 조건"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "incotermsCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>,
- meta: {
- excelHeader: "인코텀스 코드"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "incotermsDescription",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="인코텀스" />
- ),
- cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>,
- meta: {
- excelHeader: "인코텀스"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "incotermsDetail",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" />
- ),
- cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>,
- meta: {
- excelHeader: "인코텀스 상세"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "deliveryDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="납품일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "납품일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "taxCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="세금 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("taxCode")}</div>,
- meta: {
- excelHeader: "세금 코드"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "placeOfShipping",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선적지" />
- ),
- cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>,
- meta: {
- excelHeader: "선적지"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "placeOfDestination",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="도착지" />
- ),
- cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>,
- meta: {
- excelHeader: "도착지"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "materialPriceRelatedYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" />
- ),
- cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>,
- meta: {
- excelHeader: "하도급대금 연동"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "updatedByUserName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정자" />
- ),
- cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>,
- meta: {
- excelHeader: "수정자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일시" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDateTime(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "수정일시"
- },
- enableResizing: true,
- size: 140,
- },
- // 커뮤니케이션 컬럼 추가
- {
- id: "communication",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" />
- ),
- cell: ({ row }) => {
- const vendorId = row.original.vendorId || 0;
- const unreadCount = unreadMessages[vendorId] || 0;
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative p-0 h-8 w-8 flex items-center justify-center"
- onClick={() => setRowAction({ row, type: "communicate" })}
- >
- <MessageCircle className="h-4 w-4" />
- {unreadCount > 0 && (
- <Badge
- variant="destructive"
- className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
- >
- {unreadCount}
- </Badge>
- )}
- </Button>
- );
- },
- enableResizing: false,
- size: 80,
- },
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-7 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
- ]
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
deleted file mode 100644
index ad9a19e7..00000000
--- a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
+++ /dev/null
@@ -1,521 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useEffect, useState } from "react"
-import {
- DataTableRowAction,
- getRfqDetailColumns,
- RfqDetailView
-} from "./rfq-detail-column"
-import { toast } from "sonner"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { Card, CardContent } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { ProcurementRfqsView } from "@/db/schema"
-import {
- fetchCurrencies,
- fetchIncoterms,
- fetchPaymentTerms,
- fetchRfqDetails,
- fetchVendors,
- fetchUnreadMessages
-} from "@/lib/procurement-rfqs/services"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가
-import { DeleteRfqDetailDialog } from "./delete-vendor-dialog"
-import { UpdateRfqDetailSheet } from "./update-vendor-sheet"
-import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트
-
-// 프로퍼티 정의
-interface RfqDetailTablesProps {
- selectedRfq: ProcurementRfqsView | null
- maxHeight?: string | number
-}
-
-// 데이터 타입 정의
-interface Vendor {
- id: number;
- vendorName: string;
- vendorCode: string | null; // Update this to allow null
- // 기타 필요한 벤더 속성들
-}
-
-interface Currency {
- code: string;
- name: string;
-}
-
-interface PaymentTerm {
- code: string;
- description: string;
-}
-
-interface Incoterm {
- code: string;
- description: string;
-}
-
-export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) {
-
- console.log("selectedRfq", selectedRfq)
- // 상태 관리
- const [isLoading, setIsLoading] = useState(false)
- const [isRefreshing, setIsRefreshing] = useState(false)
- const [details, setDetails] = useState<RfqDetailView[]>([])
- const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
- const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
- const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
- const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
-
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [currencies, setCurrencies] = React.useState<Currency[]>([])
- const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
- const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
-
- // 벤더 커뮤니케이션 상태 관리
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
- const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
-
- // 읽지 않은 메시지 개수
- const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
- const [isUnreadLoading, setIsUnreadLoading] = useState(false)
-
- // 견적 비교 다이얼로그 상태 관리 (추가)
- const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
-
- const existingVendorIds = React.useMemo(() => {
- return details.map(detail => Number(detail.vendorId)).filter(Boolean);
- }, [details]);
-
- const handleAddVendor = async () => {
- try {
- setIsAdddialogLoading(true)
-
- // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
- const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
- fetchVendors(),
- fetchCurrencies(),
- fetchPaymentTerms(),
- fetchIncoterms()
- ])
-
- setVendors(vendorsData.data || [])
- setCurrencies(currenciesData.data || [])
- setPaymentTerms(paymentTermsData.data || [])
- setIncoterms(incotermsData.data || [])
-
- setVendorDialogOpen(true)
- } catch (error) {
- console.error("데이터 로드 오류:", error)
- toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsAdddialogLoading(false)
- }
- }
-
- // 견적 비교 다이얼로그 열기 핸들러 (추가)
- const handleOpenComparisonDialog = () => {
- // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
- const hasSubmittedQuotations = details.some(detail =>
- detail.hasQuotation && detail.quotationStatus === "Submitted"
- );
-
- if (!hasSubmittedQuotations) {
- toast.warning("제출된 견적이 없습니다.");
- return;
- }
-
- setComparisonDialogOpen(true);
- }
-
- // 읽지 않은 메시지 로드
- const loadUnreadMessages = async () => {
- if (!selectedRfq || !selectedRfq.id) return;
-
- try {
- setIsUnreadLoading(true);
-
- // 읽지 않은 메시지 수 가져오기
- const unreadData = await fetchUnreadMessages(selectedRfq.id);
- setUnreadMessages(unreadData);
- } catch (error) {
- console.error("읽지 않은 메시지 로드 오류:", error);
- // 조용히 실패 - 사용자에게 알림 표시하지 않음
- } finally {
- setIsUnreadLoading(false);
- }
- };
-
- // 칼럼 정의 - unreadMessages 상태 전달
- const columns = React.useMemo(() =>
- getRfqDetailColumns({
- setRowAction,
- unreadMessages
- }), [unreadMessages])
-
- // 필터 필드 정의 (필터 사용 시)
- const advancedFilterFields = React.useMemo(
- () => [
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "currency",
- label: "통화",
- type: "text",
- },
- ],
- []
- )
-
- // RFQ ID가 변경될 때 데이터 로드
- useEffect(() => {
- async function loadRfqDetails() {
- if (!selectedRfq || !selectedRfq.id) {
- setDetails([])
- return
- }
-
- try {
- setIsLoading(true)
- const transformRfqDetails = (data: any[]): RfqDetailView[] => {
- return data.map(item => ({
- ...item,
- // Convert vendorId from string|null to number|undefined
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- // Transform any other fields that need type conversion
- }));
- };
-
- // Then in your useEffect:
- const result = await fetchRfqDetails(selectedRfq.id);
- setDetails(transformRfqDetails(result.data));
-
- // 읽지 않은 메시지 개수 로드
- await loadUnreadMessages();
- } catch (error) {
- console.error("RFQ 디테일 로드 오류:", error)
- setDetails([])
- toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadRfqDetails()
- }, [selectedRfq])
-
- // 주기적으로 읽지 않은 메시지 갱신 (60초마다)
- useEffect(() => {
- if (!selectedRfq || !selectedRfq.id) return;
-
- const intervalId = setInterval(() => {
- loadUnreadMessages();
- }, 60000); // 60초마다 갱신
-
- return () => clearInterval(intervalId);
- }, [selectedRfq]);
-
- // rowAction 처리
- useEffect(() => {
- if (!rowAction) return
-
- const handleRowAction = async () => {
- try {
- // 통신 액션인 경우 드로어 열기
- if (rowAction.type === "communicate") {
- setSelectedVendor(rowAction.row.original);
- setCommunicationDrawerOpen(true);
-
- // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주)
- const vendorId = rowAction.row.original.vendorId;
- if (vendorId) {
- setUnreadMessages(prev => ({
- ...prev,
- [vendorId]: 0
- }));
- }
-
- // rowAction 초기화
- setRowAction(null);
- return;
- }
-
- // 다른 액션들은 기존과 동일하게 처리
- setIsAdddialogLoading(true);
-
- // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
- const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
- fetchVendors(),
- fetchCurrencies(),
- fetchPaymentTerms(),
- fetchIncoterms()
- ]);
-
- setVendors(vendorsData.data || []);
- setCurrencies(currenciesData.data || []);
- setPaymentTerms(paymentTermsData.data || []);
- setIncoterms(incotermsData.data || []);
-
- // 이제 데이터가 로드되었으므로 필요한 작업 수행
- if (rowAction.type === "update") {
- setSelectedDetail(rowAction.row.original);
- setUpdateSheetOpen(true);
- } else if (rowAction.type === "delete") {
- setSelectedDetail(rowAction.row.original);
- setDeleteDialogOpen(true);
- }
- } catch (error) {
- console.error("데이터 로드 오류:", error);
- toast.error("데이터를 불러오는 중 오류가 발생했습니다");
- } finally {
- // communicate 타입이 아닌 경우에만 로딩 상태 변경
- if (rowAction && rowAction.type !== "communicate") {
- setIsAdddialogLoading(false);
- }
- }
- };
-
- handleRowAction();
- }, [rowAction])
-
- // RFQ가 선택되지 않은 경우
- if (!selectedRfq) {
- return (
- <div className="flex h-full items-center justify-center text-muted-foreground">
- RFQ를 선택하세요
- </div>
- )
- }
-
- // 로딩 중인 경우
- if (isLoading) {
- return (
- <div className="p-4 space-y-4">
- <Skeleton className="h-8 w-1/2" />
- <Skeleton className="h-24 w-full" />
- <Skeleton className="h-48 w-full" />
- </div>
- )
- }
-
- const handleRefreshData = async () => {
- if (!selectedRfq || !selectedRfq.id) return
-
- try {
- setIsRefreshing(true)
-
- const transformRfqDetails = (data: any[]): RfqDetailView[] => {
- return data.map(item => ({
- ...item,
- // Convert vendorId from string|null to number|undefined
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- // Transform any other fields that need type conversion
- }));
- };
-
- // Then in your useEffect:
- const result = await fetchRfqDetails(selectedRfq.id);
- setDetails(transformRfqDetails(result.data));
-
- // 읽지 않은 메시지 개수 업데이트
- await loadUnreadMessages();
-
- toast.success("데이터가 새로고침되었습니다")
- } catch (error) {
- console.error("RFQ 디테일 로드 오류:", error)
- toast.error("데이터 새로고침 중 오류가 발생했습니다")
- } finally {
- setIsRefreshing(false)
- }
- }
-
- // 전체 읽지 않은 메시지 수 계산
- const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0);
-
- // 견적이 있는 벤더 수 계산
- const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length;
-
- return (
- <div className="h-full overflow-hidden pt-4">
-
- {/* 메시지 및 새로고침 영역 */}
-
-
- {/* 테이블 또는 빈 상태 표시 */}
- {details.length > 0 ? (
-
- <ClientDataTable
- columns={columns}
- data={details}
- advancedFilterFields={advancedFilterFields}
- maxHeight={maxHeight}
- >
-
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2 mr-2">
- {totalUnreadMessages > 0 && (
- <Badge variant="destructive" className="h-6">
- 읽지 않은 메시지: {totalUnreadMessages}건
- </Badge>
- )}
- {vendorsWithQuotations > 0 && (
- <Badge variant="outline" className="h-6">
- 견적 제출: {vendorsWithQuotations}개 벤더
- </Badge>
- )}
- </div>
- <div className="flex gap-2">
- {/* 견적 비교 버튼 추가 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenComparisonDialog}
- className="gap-2"
- disabled={
- !selectedRfq ||
- details.length === 0 ||
- (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate))
- }
- >
- <BarChart2 className="size-4" aria-hidden="true" />
- <span>견적 비교</span>
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isRefreshing}
- >
- {isRefreshing ? (
- <>
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- 새로고침 중...
- </>
- ) : (
- '새로고침'
- )}
- </Button>
- </div>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- className="gap-2"
- disabled={!selectedRfq || isAdddialogLoading}
- >
- {isAdddialogLoading ? (
- <>
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- <span>로딩 중...</span>
- </>
- ) : (
- <>
- <UserPlus className="size-4" aria-hidden="true" />
- <span>벤더 추가</span>
- </>
- )}
- </Button>
- </ClientDataTable>
-
- ) : (
- <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4">
- <div className="flex flex-col items-center gap-4">
- <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- className="gap-2"
- disabled={!selectedRfq || isAdddialogLoading}
- >
- {isAdddialogLoading ? (
- <>
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- <span>로딩 중...</span>
- </>
- ) : (
- <>
- <UserPlus className="size-4" aria-hidden="true" />
- <span>협력업체 추가</span>
- </>
- )}
- </Button>
- </div>
- </div>
- )}
-
- {/* 벤더 추가 다이얼로그 */}
- <AddVendorDialog
- open={vendorDialogOpen}
- onOpenChange={(open) => {
- setVendorDialogOpen(open);
- if (!open) setIsAdddialogLoading(false);
- }}
- selectedRfq={selectedRfq}
- vendors={vendors}
- currencies={currencies}
- paymentTerms={paymentTerms}
- incoterms={incoterms}
- onSuccess={handleRefreshData}
- existingVendorIds={existingVendorIds}
- />
-
- {/* 벤더 정보 수정 시트 */}
- <UpdateRfqDetailSheet
- open={updateSheetOpen}
- onOpenChange={setUpdateSheetOpen}
- detail={selectedDetail}
- vendors={vendors}
- currencies={currencies}
- paymentTerms={paymentTerms}
- incoterms={incoterms}
- onSuccess={handleRefreshData}
- />
-
- {/* 벤더 정보 삭제 다이얼로그 */}
- <DeleteRfqDetailDialog
- open={deleteDialogOpen}
- onOpenChange={setDeleteDialogOpen}
- detail={selectedDetail}
- showTrigger={false}
- onSuccess={handleRefreshData}
- />
-
- {/* 벤더 커뮤니케이션 드로어 */}
- <VendorCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={(open) => {
- setCommunicationDrawerOpen(open);
- // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신
- if (!open) loadUnreadMessages();
- }}
- selectedRfq={selectedRfq}
- selectedVendor={selectedVendor}
- onSuccess={handleRefreshData}
- />
-
- {/* 견적 비교 다이얼로그 추가 */}
- <VendorQuotationComparisonDialog
- open={comparisonDialogOpen}
- onOpenChange={setComparisonDialogOpen}
- selectedRfq={selectedRfq}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
deleted file mode 100644
index edc04788..00000000
--- a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Check, ChevronsUpDown, Loader } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Checkbox } from "@/components/ui/checkbox"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { RfqDetailView } from "./rfq-detail-column"
-import { updateRfqDetail } from "@/lib/procurement-rfqs/services"
-
-// 폼 유효성 검증 스키마
-const updateRfqDetailSchema = z.object({
- vendorId: z.string().min(1, "벤더를 선택해주세요"),
- currency: z.string().min(1, "통화를 선택해주세요"),
- paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
- incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
- incotermsDetail: z.string().optional(),
- deliveryDate: z.string().optional(),
- taxCode: z.string().optional(),
- placeOfShipping: z.string().optional(),
- placeOfDestination: z.string().optional(),
- materialPriceRelatedYn: z.boolean().default(false),
-})
-
-type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema>
-
-// 데이터 타입 정의
-interface Vendor {
- id: number;
- vendorName: string;
- vendorCode: string;
-}
-
-interface Currency {
- code: string;
- name: string;
-}
-
-interface PaymentTerm {
- code: string;
- description: string;
-}
-
-interface Incoterm {
- code: string;
- description: string;
-}
-
-interface UpdateRfqDetailSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- detail: RfqDetailView | null;
- vendors: Vendor[];
- currencies: Currency[];
- paymentTerms: PaymentTerm[];
- incoterms: Incoterm[];
- onSuccess?: () => void;
-}
-
-export function UpdateRfqDetailSheet({
- detail,
- vendors,
- currencies,
- paymentTerms,
- incoterms,
- onSuccess,
- ...props
-}: UpdateRfqDetailSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
- const form = useForm<UpdateRfqDetailFormValues>({
- resolver: zodResolver(updateRfqDetailSchema),
- defaultValues: {
- vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "",
- currency: detail?.currency || "",
- paymentTermsCode: detail?.paymentTermsCode || "",
- incotermsCode: detail?.incotermsCode || "",
- incotermsDetail: detail?.incotermsDetail || "",
- deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
- taxCode: detail?.taxCode || "",
- placeOfShipping: detail?.placeOfShipping || "",
- placeOfDestination: detail?.placeOfDestination || "",
- materialPriceRelatedYn: detail?.materialPriceRelatedYn || false,
- },
- })
-
- // detail이 변경될 때 form 값 업데이트
- React.useEffect(() => {
- if (detail) {
- const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id
-
- form.reset({
- vendorId: vendorId ? String(vendorId) : "",
- currency: detail.currency || "",
- paymentTermsCode: detail.paymentTermsCode || "",
- incotermsCode: detail.incotermsCode || "",
- incotermsDetail: detail.incotermsDetail || "",
- deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
- taxCode: detail.taxCode || "",
- placeOfShipping: detail.placeOfShipping || "",
- placeOfDestination: detail.placeOfDestination || "",
- materialPriceRelatedYn: detail.materialPriceRelatedYn || false,
- })
- }
- }, [detail, form, vendors])
-
- function onSubmit(values: UpdateRfqDetailFormValues) {
- if (!detail) return
-
- startUpdateTransition(async () => {
- try {
- const result = await updateRfqDetail(detail.detailId, values)
-
- if (!result.success) {
- toast.error(result.message || "수정 중 오류가 발생했습니다")
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("RFQ 벤더 정보가 수정되었습니다")
- onSuccess?.()
- } catch (error) {
- console.error("RFQ 벤더 수정 오류:", error)
- toast.error("수정 중 오류가 발생했습니다")
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
- <SheetHeader className="text-left">
- <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
- <SheetDescription>
- 벤더 정보를 수정하고 저장하세요
- </SheetDescription>
- </SheetHeader>
- <ScrollArea className="flex-1 pr-4">
- <Form {...form}>
- <form
- id="update-rfq-detail-form"
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- >
- {/* 검색 가능한 벤더 선택 필드 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className={cn(
- "w-full justify-between",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value
- ? vendors.find((vendor) => String(vendor.id) === field.value)
- ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
- : "벤더를 선택하세요"
- : "벤더를 선택하세요"}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput placeholder="벤더 검색..." />
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
- <ScrollArea className="h-60">
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorName} ${vendor.vendorCode}`}
- onSelect={() => {
- form.setValue("vendorId", String(vendor.id), {
- shouldValidate: true,
- })
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- String(vendor.id) === field.value
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- {vendor.vendorName} ({vendor.vendorCode})
- </CommandItem>
- ))}
- </CommandGroup>
- </ScrollArea>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.name} ({currency.code})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="paymentTermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="지불 조건 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {paymentTerms.map((term) => (
- <SelectItem key={term.code} value={term.code}>
- {term.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {incoterms.map((incoterm) => (
- <SelectItem key={incoterm.code} value={incoterm.code}>
- {incoterm.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="incotermsDetail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>인코텀즈 세부사항</FormLabel>
- <FormControl>
- <Input {...field} placeholder="인코텀즈 세부사항" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="deliveryDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>납품 예정일</FormLabel>
- <FormControl>
- <Input {...field} type="date" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="taxCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>세금 코드</FormLabel>
- <FormControl>
- <Input {...field} placeholder="세금 코드" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="placeOfShipping"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선적지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="선적지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="placeOfDestination"
- render={({ field }) => (
- <FormItem>
- <FormLabel>도착지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="도착지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="materialPriceRelatedYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel>하도급 대금 연동 여부</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </form>
- </Form>
- </ScrollArea>
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button
- type="submit"
- form="update-rfq-detail-form"
- disabled={isUpdatePending}
- >
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 저장
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
deleted file mode 100644
index e43fc676..00000000
--- a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
+++ /dev/null
@@ -1,518 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { ProcurementRfqsView } from "@/db/schema"
-import { RfqDetailView } from "./rfq-detail-column"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Badge } from "@/components/ui/badge"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X
-} from "lucide-react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime } from "@/lib/utils"
-import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
-import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services"
-
-// 타입 정의
-interface Comment {
- id: number;
- rfqId: number;
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string;
- isVendorComment: boolean | null; // null 허용으로 변경
- createdAt: Date;
- updatedAt: Date;
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[];
- isRead: boolean | null // null 허용으로 변경
-}
-
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string;
- filePath: string;
- uploadedAt: Date;
-}
-
-// 프롭스 정의
-interface VendorCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedRfq: ProcurementRfqsView | null;
- selectedVendor: RfqDetailView | null;
- onSuccess?: () => void;
-}
-
-async function sendComment(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<Comment> {
- try {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'false');
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성
- const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- // API 호출
- const response = await fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
- }
-
- // 응답 데이터 파싱
- const result = await response.json();
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- return result.data.comment;
- } catch (error) {
- console.error('코멘트 전송 오류:', error);
- throw error;
- }
-}
-
-export function VendorCommunicationDrawer({
- open,
- onOpenChange,
- selectedRfq,
- selectedVendor,
- onSuccess
-}: VendorCommunicationDrawerProps) {
- // 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && selectedRfq && selectedVendor) {
- loadComments();
- }
- }, [open, selectedRfq, selectedVendor]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 코멘트 로드 함수
- const loadComments = async () => {
- if (!selectedRfq || !selectedVendor) return;
-
- try {
- setIsLoading(true);
-
- // Server Action을 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId);
- setComments(commentsData);
-
- // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
- await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId);
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- } finally {
- setIsLoading(false);
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- console.log(newComment)
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
- console.log(!newComment.trim() && attachments.length === 0)
-
- if (!newComment.trim() && attachments.length === 0) return;
- if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
-
- console.log("버튼 클릭")
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendComment({
- rfqId: selectedRfq.id,
- vendorId: selectedVendor.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
- // TODO: 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType.startsWith("image/");
- const isPdf = selectedAttachment.fileType.includes("pdf");
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType)}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType)}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!selectedRfq || !selectedVendor) {
- return null;
- }
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[85vh]">
- <DrawerHeader className="border-b">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- {selectedVendor.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{selectedVendor.vendorName}</span>
- <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="p-0 flex flex-col h-[60vh]">
- {/* 메시지 목록 */}
- <ScrollArea className="flex-1 p-4">
- {isLoading ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
- >
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- {comment.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${
- comment.isVendorComment
- ? 'bg-muted'
- : 'bg-primary text-primary-foreground'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? comment.vendorName : comment.userName}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${
- comment.isVendorComment
- ? 'border-t border-t-border/30'
- : 'border-t border-t-primary-foreground/20'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType)}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt, "KR")}
- </div>
- </div>
-
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- {comment.userName?.[0] || 'U'}
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </ScrollArea>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t">
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
deleted file mode 100644
index 72cf187c..00000000
--- a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
+++ /dev/null
@@ -1,665 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useEffect, useState } from "react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Skeleton } from "@/components/ui/skeleton"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { toast } from "sonner"
-
-// Lucide 아이콘
-import { Plus, Minus } from "lucide-react"
-
-import { ProcurementRfqsView } from "@/db/schema"
-import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services"
-import { formatCurrency, formatDate } from "@/lib/utils"
-
-// 견적 정보 타입
-interface VendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- vendorName?: string | null
- quotationCode: string
- quotationVersion: number
- totalItemsCount: number
- subTotal: string
- taxTotal: string
- discountTotal: string
- totalPrice: string
- currency: string
- validUntil: string | Date // 수정: string | Date 허용
- estimatedDeliveryDate: string | Date // 수정: string | Date 허용
- paymentTermsCode: string
- paymentTermsDescription?: string | null
- incotermsCode: string
- incotermsDescription?: string | null
- incotermsDetail: string
- status: string
- remark: string
- rejectionReason: string
- submittedAt: string | Date // 수정: string | Date 허용
- acceptedAt: string | Date // 수정: string | Date 허용
- createdAt: string | Date // 수정: string | Date 허용
- updatedAt: string | Date // 수정: string | Date 허용
-}
-
-// 견적 아이템 타입
-interface QuotationItem {
- id: number
- quotationId: number
- prItemId: number
- materialCode: string | null // Changed from string to string | null
- materialDescription: string | null // Changed from string to string | null
- quantity: string
- uom: string | null // Changed assuming this might be null
- unitPrice: string
- totalPrice: string
- currency: string | null // Changed from string to string | null
- vendorMaterialCode: string | null // Changed from string to string | null
- vendorMaterialDescription: string | null // Changed from string to string | null
- deliveryDate: Date | null // Changed from string to string | null
- leadTimeInDays: number | null // Changed from number to number | null
- taxRate: string | null // Changed from string to string | null
- taxAmount: string | null // Changed from string to string | null
- discountRate: string | null // Changed from string to string | null
- discountAmount: string | null // Changed from string to string | null
- remark: string | null // Changed from string to string | null
- isAlternative: boolean | null // Changed from boolean to boolean | null
- isRecommended: boolean | null // Changed from boolean to boolean | null
-}
-
-interface VendorQuotationComparisonDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: ProcurementRfqsView | null
-}
-
-export function VendorQuotationComparisonDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: VendorQuotationComparisonDialogProps) {
- const [isLoading, setIsLoading] = useState(false)
- const [quotations, setQuotations] = useState<VendorQuotation[]>([])
- const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({})
- const [activeTab, setActiveTab] = useState("summary")
-
- // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘
- const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({})
-
- useEffect(() => {
- async function loadQuotationData() {
- if (!open || !selectedRfq?.id) return
-
- try {
- setIsLoading(true)
- // 1) 견적 목록
- const quotationsResult = await fetchVendorQuotations(selectedRfq.id)
- const rawQuotationsData = quotationsResult.data || []
-
- const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({
- id: rawData.id,
- rfqId: rawData.rfqId,
- vendorId: rawData.vendorId,
- vendorName: rawData.vendorName || null,
- quotationCode: rawData.quotationCode || '',
- quotationVersion: rawData.quotationVersion || 0,
- totalItemsCount: rawData.totalItemsCount || 0,
- subTotal: rawData.subTotal || '0',
- taxTotal: rawData.taxTotal || '0',
- discountTotal: rawData.discountTotal || '0',
- totalPrice: rawData.totalPrice || '0',
- currency: rawData.currency || 'KRW',
- validUntil: rawData.validUntil || '',
- estimatedDeliveryDate: rawData.estimatedDeliveryDate || '',
- paymentTermsCode: rawData.paymentTermsCode || '',
- paymentTermsDescription: rawData.paymentTermsDescription || null,
- incotermsCode: rawData.incotermsCode || '',
- incotermsDescription: rawData.incotermsDescription || null,
- incotermsDetail: rawData.incotermsDetail || '',
- status: rawData.status || '',
- remark: rawData.remark || '',
- rejectionReason: rawData.rejectionReason || '',
- submittedAt: rawData.submittedAt || '',
- acceptedAt: rawData.acceptedAt || '',
- createdAt: rawData.createdAt || '',
- updatedAt: rawData.updatedAt || '',
- }));
-
- setQuotations(quotationsData);
-
- // 벤더별로 접힘 상태 기본값(true) 설정
- const collapsedInit: Record<number, boolean> = {}
- quotationsData.forEach((q) => {
- collapsedInit[q.id] = true
- })
- setCollapsedVendors(collapsedInit)
-
- // 2) 견적 아이템
- const qIds = quotationsData.map((q) => q.id)
- if (qIds.length > 0) {
- const itemsResult = await fetchQuotationItems(qIds)
- const itemsData = itemsResult.data || []
-
- const itemsByQuotation: Record<number, QuotationItem[]> = {}
- itemsData.forEach((item) => {
- if (!itemsByQuotation[item.quotationId]) {
- itemsByQuotation[item.quotationId] = []
- }
- itemsByQuotation[item.quotationId].push(item)
- })
- setQuotationItems(itemsByQuotation)
- }
- } catch (error) {
- console.error("견적 데이터 로드 오류:", error)
- toast.error("견적 데이터를 불러오는 데 실패했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadQuotationData()
- }, [open, selectedRfq])
-
- // 견적 상태 -> 뱃지 색
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "Submitted":
- return "default"
- case "Accepted":
- return "default"
- case "Rejected":
- return "destructive"
- case "Revised":
- return "destructive"
- default:
- return "secondary"
- }
- }
-
- // 모든 prItemId 모음
- const allItemIds = React.useMemo(() => {
- const itemSet = new Set<number>()
- Object.values(quotationItems).forEach((items) => {
- items.forEach((it) => itemSet.add(it.prItemId))
- })
- return Array.from(itemSet)
- }, [quotationItems])
-
- // 아이템 찾는 함수
- const findItemByQuotationId = (prItemId: number, qid: number) => {
- const items = quotationItems[qid] || []
- return items.find((i) => i.prItemId === prItemId)
- }
-
- // 접힘 상태 토글
- const toggleVendor = (qid: number) => {
- setCollapsedVendors((prev) => ({
- ...prev,
- [qid]: !prev[qid],
- }))
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */}
- <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}>
- <DialogHeader>
- <DialogTitle>벤더 견적 비교</DialogTitle>
- <DialogDescription>
- {selectedRfq
- ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}`
- : ""}
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="space-y-4">
- <Skeleton className="h-8 w-1/2" />
- <Skeleton className="h-48 w-full" />
- </div>
- ) : quotations.length === 0 ? (
- <div className="py-8 text-center text-muted-foreground">
- 제출된(Submitted) 견적이 없습니다
- </div>
- ) : (
- <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="summary">견적 요약 비교</TabsTrigger>
- <TabsTrigger value="items">아이템별 비교</TabsTrigger>
- </TabsList>
-
- {/* ======================== 요약 비교 탭 ======================== */}
- <TabsContent value="summary" className="mt-4">
- {/*
- table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px])
- -> 컨테이너보다 넓으면 수평 스크롤 발생.
- */}
- <div className="border rounded-md max-h-[60vh] overflow-auto">
- <table className="table-fixed w-full border-collapse">
- <thead className="sticky top-0 bg-background z-10">
- <TableRow>
- <TableHead
- className="sticky left-0 top-0 z-20 bg-background p-2"
- >
- 항목
- </TableHead>
- {quotations.map((q) => (
- <TableHead key={q.id} className="p-2 text-center whitespace-nowrap">
- {q.vendorName || `벤더 ID: ${q.vendorId}`}
- </TableHead>
- ))}
- </TableRow>
- </thead>
- <tbody>
- {/* 견적 상태 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 견적 상태
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`status-${q.id}`} className="p-2">
- <Badge variant={getStatusBadgeVariant(q.status)}>
- {q.status}
- </Badge>
- </TableCell>
- ))}
- </TableRow>
-
- {/* 견적 버전 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 견적 버전
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`version-${q.id}`} className="p-2">
- v{q.quotationVersion}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 총 금액 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 총 금액
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`total-${q.id}`} className="p-2 font-semibold">
- {formatCurrency(Number(q.totalPrice), q.currency)}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 소계 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 소계
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`subtotal-${q.id}`} className="p-2">
- {formatCurrency(Number(q.subTotal), q.currency)}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 세금 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 세금
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`tax-${q.id}`} className="p-2">
- {formatCurrency(Number(q.taxTotal), q.currency)}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 할인 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 할인
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`discount-${q.id}`} className="p-2">
- {formatCurrency(Number(q.discountTotal), q.currency)}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 통화 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 통화
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`currency-${q.id}`} className="p-2">
- {q.currency}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 유효기간 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 유효 기간
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`valid-${q.id}`} className="p-2">
- {formatDate(q.validUntil, "KR")}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 예상 배송일 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 예상 배송일
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`delivery-${q.id}`} className="p-2">
- {formatDate(q.estimatedDeliveryDate, "KR")}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 지불 조건 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 지불 조건
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`payment-${q.id}`} className="p-2">
- {q.paymentTermsDescription || q.paymentTermsCode}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 인코텀즈 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 인코텀즈
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`incoterms-${q.id}`} className="p-2">
- {q.incotermsDescription || q.incotermsCode}
- {q.incotermsDetail && (
- <div className="text-xs text-muted-foreground mt-1">
- {q.incotermsDetail}
- </div>
- )}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 제출일 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 제출일
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`submitted-${q.id}`} className="p-2">
- {formatDate(q.submittedAt, "KR")}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 비고 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 비고
- </TableCell>
- {quotations.map((q) => (
- <TableCell
- key={`remark-${q.id}`}
- className="p-2 whitespace-pre-wrap"
- >
- {q.remark || "-"}
- </TableCell>
- ))}
- </TableRow>
- </tbody>
- </table>
- </div>
- </TabsContent>
-
- {/* ====================== 아이템별 비교 탭 ====================== */}
- <TabsContent value="items" className="mt-4">
- {/* 컨테이너에 테이블 관련 클래스 직접 적용 */}
- <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" >
- <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}>
- <table className="w-full border-collapse">
- <thead className="sticky top-0 bg-background z-10">
- {/* 첫 번째 헤더 행 */}
- <tr>
- {/* 첫 행: 자재(코드) 컬럼 */}
- <th
- rowSpan={2}
- className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left"
- style={{
- width: '250px',
- minWidth: '250px',
- backgroundColor: 'white',
- }}
- >
- 자재 (코드)
- </th>
-
- {/* 벤더 헤더 (접힘/펼침) */}
- {quotations.map((q, index) => {
- const collapsed = collapsedVendors[q.id]
- // 접힌 상태면 1칸, 펼친 상태면 6칸
- return (
- <th
- key={q.id}
- className="p-2 text-center whitespace-nowrap border border-gray-200"
- colSpan={collapsed ? 1 : 6}
- style={{
- borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '',
- backgroundColor: 'white',
- }}
- >
- {/* + / - 버튼 */}
- <div className="flex items-center gap-2 justify-center">
- <Button
- variant="ghost"
- size="sm"
- className="h-7 w-7 p-1"
- onClick={() => toggleVendor(q.id)}
- >
- {collapsed ? <Plus size={16} /> : <Minus size={16} />}
- </Button>
- <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
- </div>
- </th>
- )
- })}
- </tr>
-
- {/* 두 번째 헤더 행 - 하위 컬럼들 */}
- <tr className="border-b border-b-gray-200">
- {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */}
- {quotations.flatMap((q, qIndex) => {
- // 접힌 상태면 추가 헤더 없음
- if (collapsedVendors[q.id]) {
- return [
- <th
- key={`${q.id}-collapsed`}
- className="p-2 text-center whitespace-nowrap border border-gray-200"
- style={{ backgroundColor: 'white' }}
- >
- 총액
- </th>
- ];
- }
-
- // 펼친 상태면 6개 컬럼 표시
- const columns = [
- { key: 'unitprice', label: '단가' },
- { key: 'totalprice', label: '총액' },
- { key: 'tax', label: '세금' },
- { key: 'discount', label: '할인' },
- { key: 'leadtime', label: '리드타임' },
- { key: 'alternative', label: '대체품' },
- ];
-
- return columns.map((col, colIndex) => {
- const isFirstInGroup = colIndex === 0;
- const isLastInGroup = colIndex === columns.length - 1;
-
- return (
- <th
- key={`${q.id}-${col.key}`}
- className={`p-2 text-center whitespace-nowrap border border-gray-200 ${
- isFirstInGroup ? 'border-l border-l-gray-200' : ''
- } ${
- isLastInGroup ? 'border-r border-r-gray-200' : ''
- }`}
- style={{ backgroundColor: 'white' }}
- >
- {col.label}
- </th>
- );
- });
- })}
- </tr>
- </thead>
-
- {/* 테이블 바디 */}
- <tbody>
- {allItemIds.map((itemId) => {
- // 자재 기본 정보는 첫 번째 벤더 아이템 기준
- const firstQid = quotations[0]?.id
- const sampleItem = firstQid
- ? findItemByQuotationId(itemId, firstQid)
- : undefined
-
- return (
- <tr key={itemId} className="border-b border-gray-100">
- {/* 자재 (코드) 셀 */}
- <td
- className="sticky left-0 z-10 p-2 align-top border-r border-gray-100"
- style={{
- width: '250px',
- minWidth: '250px',
- backgroundColor: 'white',
- }}
- >
- {sampleItem?.materialDescription || sampleItem?.materialCode || ""}
- {sampleItem && (
- <div className="text-xs text-muted-foreground mt-1">
- 코드: {sampleItem.materialCode} | 수량:{" "}
- {sampleItem.quantity} {sampleItem.uom}
- </div>
- )}
- </td>
-
- {/* 벤더별 아이템 데이터 */}
- {quotations.flatMap((q, qIndex) => {
- const collapsed = collapsedVendors[q.id]
- const itemData = findItemByQuotationId(itemId, q.id)
-
- // 접힌 상태면 총액만 표시
- if (collapsed) {
- return [
- <td
- key={`${q.id}-collapsed`}
- className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100"
- >
- {itemData
- ? formatCurrency(Number(itemData.totalPrice), itemData.currency)
- : "N/A"}
- </td>
- ];
- }
-
- // 펼친 상태 - 아이템 없음
- if (!itemData) {
- return [
- <td
- key={`${q.id}-empty`}
- colSpan={6}
- className="p-2 text-center text-sm border-r border-gray-100"
- >
- 없음
- </td>
- ];
- }
-
- // 펼친 상태 - 모든 컬럼 표시
- const columns = [
- { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' },
- { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true },
- { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' },
- { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' },
- { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' },
- { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' },
- ];
-
- return columns.map((col, colIndex) => {
- const isFirstInGroup = colIndex === 0;
- const isLastInGroup = colIndex === columns.length - 1;
-
- return (
- <td
- key={`${q.id}-${col.key}`}
- className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${
- isFirstInGroup ? 'border-l border-l-gray-100' : ''
- } ${
- isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100'
- }`}
- >
- {col.render()}
- </td>
- );
- });
- })}
- </tr>
- );
- })}
-
- {/* 아이템이 전혀 없는 경우 */}
- {allItemIds.length === 0 && (
- <tr>
- <td
- colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버
- className="text-center p-4 border border-gray-100"
- >
- 아이템 정보가 없습니다
- </td>
- </tr>
- )}
- </tbody>
- </table>
- </div>
- </div>
- </TabsContent>
- </Tabs>
- )}
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx
deleted file mode 100644
index aada8438..00000000
--- a/lib/procurement-rfqs/table/pr-item-dialog.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { useState, useEffect } from "react";
-import { formatDate } from "@/lib/utils";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Badge } from "@/components/ui/badge";
-import { ProcurementRfqsView } from "@/db/schema";
-import { fetchPrItemsByRfqId } from "../services";
-import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { Input } from "@/components/ui/input";
-import { Search } from "lucide-react";
-
-// PR 항목 타입 정의
-interface PrItemView {
- id: number;
- procurementRfqsId: number;
- rfqItem: string | null;
- prItem: string | null;
- prNo: string | null;
- itemId: number | null;
- materialCode: string | null;
- materialCategory: string | null;
- acc: string | null;
- materialDescription: string | null;
- size: string | null;
- deliveryDate: Date | null;
- quantity: number | null;
- uom: string | null;
- grossWeight: number | null;
- gwUom: string | null;
- specNo: string | null;
- specUrl: string | null;
- trackingNo: string | null;
- majorYn: boolean | null;
- projectDef: string | null;
- projectSc: string | null;
- projectKl: string | null;
- projectLc: string | null;
- projectDl: string | null;
- remark: string | null;
- rfqCode: string | null;
- itemCode: string | null;
- itemName: string | null;
-}
-
-interface PrDetailsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedRfq: ProcurementRfqsView | null;
-}
-
-export function PrDetailsDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: PrDetailsDialogProps) {
- const [isLoading, setIsLoading] = useState(false);
- const [prItems, setPrItems] = useState<PrItemView[]>([]);
- const [searchTerm, setSearchTerm] = useState("");
-
- // 검색어로 필터링된 항목들
- const filteredItems = React.useMemo(() => {
- if (!searchTerm.trim()) return prItems;
-
- const term = searchTerm.toLowerCase();
- return prItems.filter(item =>
- (item.materialDescription || "").toLowerCase().includes(term) ||
- (item.materialCode || "").toLowerCase().includes(term) ||
- (item.prNo || "").toLowerCase().includes(term) ||
- (item.prItem || "").toLowerCase().includes(term) ||
- (item.rfqItem || "").toLowerCase().includes(term)
- );
- }, [prItems, searchTerm]);
-
- // 선택된 RFQ가 변경되면 PR 항목 데이터를 가져옴
- useEffect(() => {
- async function loadPrItems() {
- if (!selectedRfq || !open) {
- setPrItems([]);
- return;
- }
-
- try {
- setIsLoading(true);
- const result = await fetchPrItemsByRfqId(selectedRfq.id);
- const mappedItems: PrItemView[] = result.data.map(item => ({
- ...item,
- // procurementRfqsId가 null이면 selectedRfq.id 사용
- procurementRfqsId: item.procurementRfqsId ?? selectedRfq.id,
- // 기타 필요한 필드에 대한 기본값 처리
- rfqItem: item.rfqItem ?? null,
- prItem: item.prItem ?? null,
- prNo: item.prNo ?? null,
- // 다른 필드도 필요에 따라 추가
- }));
-
- setPrItems(mappedItems);
- } catch (error) {
- console.error("PR 항목 로드 오류:", error);
- setPrItems([]);
- } finally {
- setIsLoading(false);
- }
- }
-
- if (open) {
- loadPrItems();
- setSearchTerm("");
- }
- }, [selectedRfq, open]);
-
- // 선택된 RFQ가 없는 경우
- if (!selectedRfq) {
- return null;
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-screen-sm max-h-[90vh] flex flex-col" style={{ maxWidth: "70vw" }}>
- <DialogHeader>
- <DialogTitle className="text-xl">
- PR 상세 정보 - {selectedRfq.rfqCode}
- </DialogTitle>
- <DialogDescription>
- 프로젝트: {selectedRfq.projectName} ({selectedRfq.projectCode}) | 건수:{" "}
- {selectedRfq.prItemsCount || 0}건
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="py-4 space-y-3">
- <Skeleton className="h-8 w-full" />
- <Skeleton className="h-24 w-full" />
- <Skeleton className="h-24 w-full" />
- </div>
- ) : (
- <div className="flex-1 flex flex-col">
- {/* 검색 필드 */}
- <div className="mb-4 relative">
- <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
- <Search className="h-4 w-4 text-muted-foreground" />
- </div>
- <Input
- placeholder="PR 번호, 자재 코드, 설명 등 검색..."
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- className="pl-8"
- />
-</div>
- {filteredItems.length === 0 ? (
- <div className="flex items-center justify-center py-8 text-muted-foreground border rounded-md">
- {prItems.length === 0 ? "PR 항목이 없습니다" : "검색 결과가 없습니다"}
- </div>
- ) : (
- <div className="rounded-md border flex-1 overflow-hidden">
- <div className="overflow-x-auto" style={{ width: "100%" }}>
- <Table style={{ minWidth: "2500px" }}>
- <TableCaption>
- 총 {filteredItems.length}개 항목 (전체 {prItems.length}개 중)
- </TableCaption>
- <TableHeader className="bg-muted/50 sticky top-0">
- <TableRow>
- <TableHead className="w-[100px] whitespace-nowrap">RFQ Item</TableHead>
- <TableHead className="w-[120px] whitespace-nowrap">PR 번호</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">PR Item</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">자재그룹</TableHead>
- <TableHead className="w-[120px] whitespace-nowrap">자재 코드</TableHead>
- <TableHead className="w-[120px] whitespace-nowrap">자재 카테고리</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">ACC</TableHead>
- <TableHead className="min-w-[200px] whitespace-nowrap">자재 설명</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">규격</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">납품일</TableHead>
- <TableHead className="w-[80px] whitespace-nowrap">수량</TableHead>
- <TableHead className="w-[80px] whitespace-nowrap">UOM</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">총중량</TableHead>
- <TableHead className="w-[80px] whitespace-nowrap">중량 단위</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">사양 번호</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">사양 URL</TableHead>
- <TableHead className="w-[120px] whitespace-nowrap">추적 번호</TableHead>
- <TableHead className="w-[80px] whitespace-nowrap">주요 항목</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DEF</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">프로젝트 SC</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">프로젝트 KL</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">프로젝트 LC</TableHead>
- <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DL</TableHead>
- <TableHead className="w-[150px] whitespace-nowrap">비고</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {filteredItems.map((item) => (
- <TableRow key={item.id}>
- <TableCell className="whitespace-nowrap">{item.rfqItem || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.prNo || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.prItem || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.itemCode || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.materialCode || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.materialCategory || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.acc || "-"}</TableCell>
- <TableCell>{item.materialDescription || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.size || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">
- {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"}
- </TableCell>
- <TableCell className="whitespace-nowrap">{item.quantity || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.uom || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.grossWeight || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.gwUom || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.specNo || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.specUrl || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.trackingNo || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">
- {item.majorYn ? (
- <Badge variant="secondary">주요</Badge>
- ) : (
- "아니오"
- )}
- </TableCell>
- <TableCell className="whitespace-nowrap">{item.projectDef || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.projectSc || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.projectKl || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.projectLc || "-"}</TableCell>
- <TableCell className="whitespace-nowrap">{item.projectDl || "-"}</TableCell>
- <TableCell className="text-sm">{item.remark || "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- </div>
- )}
- </div>
- )}
-
- <DialogFooter className="mt-2">
- <Button onClick={() => onOpenChange(false)}>닫기</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx
deleted file mode 100644
index a746603b..00000000
--- a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx
+++ /dev/null
@@ -1,686 +0,0 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, ChevronRight, Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { useTranslation } from '@/i18n/client'
-import { getFiltersStateParser } from "@/lib/parsers"
-import { DateRangePicker } from "@/components/date-range-picker"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// 필터 스키마 정의 (RFQ 관련 항목 유지)
-const filterSchema = z.object({
- picCode: z.string().optional(),
- projectCode: z.string().optional(),
- rfqCode: z.string().optional(),
- itemCode: z.string().optional(),
- majorItemMaterialCode: z.string().optional(),
- status: z.string().optional(),
- dateRange: z.object({
- from: z.date().optional(),
- to: z.date().optional(),
- }).optional(),
-})
-
-// 상태 옵션 정의
-const statusOptions = [
- { value: "RFQ Created", label: "RFQ Created" },
- { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
- { value: "RFQ Sent", label: "RFQ Sent" },
- { value: "Quotation Analysis", label: "Quotation Analysis" },
- { value: "PO Transfer", label: "PO Transfer" },
- { value: "PO Create", label: "PO Create" },
-]
-
-type FilterFormValues = z.infer<typeof filterSchema>
-
-interface RFQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-// Updated component for inline use (not a sheet anymore)
-export function RFQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: RFQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
- const { t } = useTranslation(lng);
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<FilterFormValues>({
- resolver: zodResolver(filterSchema),
- defaultValues: {
- picCode: "",
- projectCode: "",
- rfqCode: "",
- itemCode: "",
- majorItemMaterialCode: "",
- status: "",
- dateRange: {
- from: undefined,
- to: undefined,
- },
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
- formValues.dateRange = {
- from: filter.value[0] ? new Date(filter.value[0]) : undefined,
- to: filter.value[1] ? new Date(filter.value[1]) : undefined,
- };
- formUpdated = true;
- } else if (filter.id in formValues) {
- // @ts-ignore - 동적 필드 접근
- formValues[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen]) // form 의존성 제거
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
- // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전)
- async function onSubmit(data: FilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.picCode?.trim()) {
- newFilters.push({
- id: "picCode",
- value: data.picCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.projectCode?.trim()) {
- newFilters.push({
- id: "projectCode",
- value: data.projectCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.rfqCode?.trim()) {
- newFilters.push({
- id: "rfqCode",
- value: data.rfqCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.itemCode?.trim()) {
- newFilters.push({
- id: "itemCode",
- value: data.itemCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.majorItemMaterialCode?.trim()) {
- newFilters.push({
- id: "majorItemMaterialCode",
- value: data.majorItemMaterialCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // Add date range to params if it exists
- if (data.dateRange?.from) {
- newFilters.push({
- id: "rfqSendDate",
- value: [
- data.dateRange.from.toISOString().split('T')[0],
- data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
- ].filter(Boolean),
- type: "date",
- operator: "isBetween",
- rowId: generateId()
- })
- }
-
- console.log("=== RFQ Filter Submit Debug ===");
- console.log("Generated filters:", newFilters);
- console.log("Join operator:", joinOperator);
-
- // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신)
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 기존 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('basicJoinOperator');
- params.delete('page');
-
- // 새로운 필터 추가
- if (newFilters.length > 0) {
- params.set('basicFilters', JSON.stringify(newFilters));
- params.set('basicJoinOperator', joinOperator);
- }
-
- // 페이지를 1로 설정
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("New URL:", newUrl);
-
- // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- if (onSearch) {
- console.log("Calling onSearch...");
- onSearch();
- }
-
- console.log("=== RFQ Filter Submit Complete ===");
- } catch (error) {
- console.error("RFQ 필터 적용 오류:", error);
- }
- })
- }
-
- // 필터 초기화 핸들러 - PQ 방식으로 수정
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- picCode: "",
- projectCode: "",
- rfqCode: "",
- itemCode: "",
- majorItemMaterialCode: "",
- status: "",
- dateRange: { from: undefined, to: undefined },
- });
-
- console.log("=== RFQ Filter Reset Debug ===");
- console.log("Current URL before reset:", window.location.href);
-
- // 🔑 PQ 방식: 수동으로 URL 초기화
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('basicJoinOperator');
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("Reset URL:", newUrl);
-
- // 🔑 PQ 방식: 페이지 완전 새로고침
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("RFQ 필터 초기화 완료");
- setIsInitializing(false);
- } catch (error) {
- console.error("RFQ 필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
- <div className="flex items-center gap-2">
- {getActiveFilterCount() > 0 && (
- <Badge variant="secondary" className="px-2 py-1">
- {getActiveFilterCount()}개 필터 적용됨
- </Badge>
- )}
- </div>
- </div>
-
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-4 pt-2">
- {/* 발주 담당 */}
- <FormField
- control={form.control}
- name="picCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("발주담당")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("발주담당 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("picCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트 코드 */}
- <FormField
- control={form.control}
- name="projectCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("프로젝트 코드")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("프로젝트 코드 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("projectCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ NO. */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ NO.")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("RFQ 번호 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("rfqCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재그룹 */}
- <FormField
- control={form.control}
- name="itemCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재그룹")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재그룹 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("itemCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재코드 */}
- <FormField
- control={form.control}
- name="majorItemMaterialCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재코드")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재코드 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("majorItemMaterialCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Status */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("Status")}</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder={t("Select status")} />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {statusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 전송일 */}
- <FormField
- control={form.control}
- name="dateRange"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ 전송일")}</FormLabel>
- <FormControl>
- <div className="relative">
- <DateRangePicker
- triggerSize="default"
- triggerClassName="w-full bg-white"
- align="start"
- showClearButton={true}
- placeholder={t("RFQ 전송일 범위를 고르세요")}
- value={field.value || undefined}
- onChange={field.onChange}
- disabled={isInitializing}
- />
- {(field.value?.from || field.value?.to) && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-10 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("dateRange", { from: undefined, to: undefined });
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- {t("초기화")}
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? t("조회 중...") : t("조회")}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-column.tsx b/lib/procurement-rfqs/table/rfq-table-column.tsx
deleted file mode 100644
index 3cf06315..00000000
--- a/lib/procurement-rfqs/table/rfq-table-column.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ColumnDef } from "@tanstack/react-table"
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { DataTableRowAction } from "@/types/table"
-import { ProcurementRfqsView } from "@/db/schema"
-import { Check, Pencil, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { toast } from "sonner"
-import { Input } from "@/components/ui/input"
-import { updateRfqRemark } from "../services"
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementRfqsView> | null>>;
- // 상태와 상태 설정 함수를 props로 받음
- editingCell: EditingCellState | null;
- setEditingCell: (state: EditingCellState | null) => void;
- updateRemark: (rfqId: number, remark: string) => Promise<void>;
-}
-
-export interface EditingCellState {
- rowId: string | number;
- value: string;
-}
-
-
-export function getColumns({
- setRowAction,
- editingCell,
- setEditingCell,
- updateRemark,
-}: GetColumnsProps): ColumnDef<ProcurementRfqsView>[] {
-
-
-
- return [
- {
- id: "select",
- // Remove the "Select all" checkbox in header since we're doing single-select
- header: () => <span className="sr-only">Select</span>,
- cell: ({ row, table }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // If selecting this row
- if (value) {
- // First deselect all rows (to ensure single selection)
- table.toggleAllRowsSelected(false)
- // Then select just this row
- row.toggleSelected(true)
- // Trigger the same action that was in the "Select" button
- setRowAction({ row, type: "select" })
- } else {
- // Just deselect this row
- row.toggleSelected(false)
- }
- }}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- enableResizing: false,
- size: 40,
- minSize: 40,
- maxSize: 40,
- },
-
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="status" />
- ),
- cell: ({ row }) => <div>{row.getValue("status")}</div>,
- meta: {
- excelHeader: "status"
- },
- enableResizing: true,
- minSize: 80,
- size: 100,
- },
- {
- accessorKey: "projectCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트" />
- ),
- cell: ({ row }) => <div>{row.getValue("projectCode")}</div>,
- meta: {
- excelHeader: "프로젝트"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "series",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="시리즈" />
- ),
- cell: ({ row }) => <div>{row.getValue("series")}</div>,
- meta: {
- excelHeader: "시리즈"
- },
- enableResizing: true,
- minSize: 80,
- size: 100,
- },
- {
- accessorKey: "rfqSealedYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 밀봉" />
- ),
- cell: ({ row }) => <div>{row.getValue("rfqSealedYn") ? "Y":"N"}</div>,
- meta: {
- excelHeader: "RFQ 밀봉"
- },
- enableResizing: true,
- minSize: 80,
- size: 100,
- },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ NO." />
- ),
- cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
- meta: {
- excelHeader: "RFQ NO."
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "po_no",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="대표 PR NO." />
- ),
- cell: ({ row }) => <div>{row.getValue("po_no")}</div>,
- meta: {
- excelHeader: "대표 PR NO."
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "itemCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재그룹" />
- ),
- cell: ({ row }) => <div>{row.getValue("itemCode")}</div>,
- meta: {
- excelHeader: "자재그룹"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "majorItemMaterialCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("majorItemMaterialCode")}</div>,
- meta: {
- excelHeader: "자재코드"
- },
- enableResizing: true,
- minSize: 80,
- size: 120,
- },
- {
- accessorKey: "itemName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재명" />
- ),
- cell: ({ row }) => <div>{row.getValue("itemName")}</div>,
- meta: {
- excelHeader: "자재명"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "prItemsCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="PR 건수" />
- ),
- cell: ({ row }) => <div>{row.getValue("prItemsCount")}</div>,
- meta: {
- excelHeader: "PR 건수"
- },
- enableResizing: true,
- // size: 80,
- },
- {
- accessorKey: "rfqSendDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "RFQ 전송일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "earliestQuotationSubmittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첫회신 접수일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "첫회신 접수일"
- },
- enableResizing: true,
- // size: 140,
- },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "RFQ 마감일"
- },
- enableResizing: true,
- minSize: 80,
- size: 120,
- },
- {
- accessorKey: "sentByUserName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 요청자" />
- ),
- cell: ({ row }) => <div>{row.getValue("sentByUserName")}</div>,
- meta: {
- excelHeader: "RFQ 요청자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Updated At" />
- ),
- cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"),
- meta: {
- excelHeader: "updated At"
- },
- enableResizing: true,
- size: 140,
- },
-
- {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => {
- const rowId = row.id
- const value = row.getValue("remark") as string
- const isEditing = editingCell && editingCell.rowId === rowId
-
- const startEditing = () => {
- setEditingCell({
- rowId,
- value: value || ""
- })
- }
-
- const cancelEditing = () => {
- setEditingCell(null)
- }
-
- const saveChanges = async () => {
- if (!editingCell) return
-
- try {
-
- // 컴포넌트에서 전달받은 업데이트 함수 사용
- await updateRemark(row.original.id, editingCell.value)
- row.original.remark = editingCell.value;
-
- // 편집 모드 종료
- setEditingCell(null)
- } catch (error) {
- console.error("비고 업데이트 오류:", error)
- }
- }
-
- // 키보드 이벤트 처리
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- saveChanges()
- } else if (e.key === "Escape") {
- cancelEditing()
- }
- }
-
- if (isEditing) {
- return (
- <div className="flex items-center space-x-1">
- <Input
- value={editingCell.value}
- onChange={(e) => setEditingCell({
- ...editingCell,
- value: e.target.value
- })}
- onKeyDown={handleKeyDown}
- autoFocus
- className="h-8 w-full"
- />
- <div className="flex items-center">
- <Button
- variant="ghost"
- size="icon"
- onClick={saveChanges}
- className="h-7 w-7"
- >
- <Check className="h-4 w-4 text-green-500" />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- onClick={cancelEditing}
- className="h-7 w-7"
- >
- <X className="h-4 w-4 text-red-500" />
- </Button>
- </div>
- </div>
- )
- }
-
- return (
- <div
- className="flex items-center justify-between group"
- onDoubleClick={startEditing} // 더블클릭 이벤트 추가
- >
- <div className="truncate">{value || "-"}</div>
- <Button
- variant="ghost"
- size="icon"
- onClick={startEditing}
- className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
- >
- <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
- </Button>
- </div>
- )
- },
- meta: {
- excelHeader: "비고"
- },
- enableResizing: true,
- size: 200,
- }
- ]
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
deleted file mode 100644
index 26725797..00000000
--- a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { ClipboardList, Download, Send, Lock, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { ProcurementRfqsView } from "@/db/schema"
-import { PrDetailsDialog } from "./pr-item-dialog"
-import { sealRfq, sendRfq, getPORfqs, fetchExternalRfqs } from "../services"
-
-// total 필드 추가하여 타입 정의 수정
-type PORfqsReturn = Awaited<ReturnType<typeof getPORfqs>>
-
-interface RFQTableToolbarActionsProps {
- table: Table<ProcurementRfqsView>;
- // 타입 수정
- localData?: PORfqsReturn;
- setLocalData?: React.Dispatch<React.SetStateAction<PORfqsReturn>>;
- onSuccess?: () => void;
-}
-
-export function RFQTableToolbarActions({
- table,
- localData,
- setLocalData,
- onSuccess
-}: RFQTableToolbarActionsProps) {
- // 다이얼로그 열림/닫힘 상태 관리
- const [dialogOpen, setDialogOpen] = React.useState(false)
- const [isProcessing, setIsProcessing] = React.useState(false)
-
- // 선택된 RFQ 가져오기
- const getSelectedRfq = (): ProcurementRfqsView | null => {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- if (selectedRows.length === 1) {
- return selectedRows[0].original
- }
- return null
- }
-
- // 선택된 RFQ
- const selectedRfq = getSelectedRfq()
-
- // PR 상세보기 버튼 클릭 핸들러
- const handleViewPrDetails = () => {
- const rfq = getSelectedRfq()
- if (!rfq) {
- toast.warning("RFQ를 선택해주세요")
- return
- }
-
- if (!rfq.prItemsCount || rfq.prItemsCount <= 0) {
- toast.warning("선택한 RFQ에 PR 항목이 없습니다")
- return
- }
-
- setDialogOpen(true)
- }
-
- // RFQ 밀봉 버튼 클릭 핸들러
- const handleSealRfq = async () => {
- const rfq = getSelectedRfq()
- if (!rfq) {
- toast.warning("RFQ를 선택해주세요")
- return
- }
-
- // 이미 밀봉된 RFQ인 경우
- if (rfq.rfqSealedYn) {
- toast.warning("이미 밀봉된 RFQ입니다")
- return
- }
-
- try {
- setIsProcessing(true)
-
- // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
- if (localData?.data && setLocalData) {
- // 로컬 데이터에서 해당 행 찾기
- const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
- if (rowIndex >= 0) {
- // 불변성을 유지하면서 로컬 데이터 업데이트 - 타입 안전하게 복사
- const newData = [...localData.data] as ProcurementRfqsView[];
- newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: "Y" };
-
- // 전체 데이터 구조 복사하여 업데이트, total 필드가 있다면 유지
- setLocalData({
- ...localData,
- data: newData ?? [],
- pageCount: localData.pageCount,
- total: localData.total ?? 0
- });
- }
- }
-
- const result = await sealRfq(rfq.id)
-
- if (result.success) {
- toast.success("RFQ가 성공적으로 밀봉되었습니다")
- // 데이터 리프레시
- onSuccess?.()
- } else {
- toast.error(result.message || "RFQ 밀봉 중 오류가 발생했습니다")
-
- // 서버 요청 실패 시 낙관적 업데이트 되돌리기
- if (localData?.data && setLocalData) {
- const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
- if (rowIndex >= 0) {
- const newData = [...localData.data] as ProcurementRfqsView[];
- newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
- setLocalData({
- ...localData,
- data: newData ?? [],
- pageCount: localData.pageCount,
- total: localData.total ?? 0
- });
- }
- }
- }
- } catch (error) {
- console.error("RFQ 밀봉 오류:", error)
- toast.error("RFQ 밀봉 중 오류가 발생했습니다")
-
- // 에러 발생 시 낙관적 업데이트 되돌리기
- if (localData?.data && setLocalData) {
- const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
- if (rowIndex >= 0) {
- const newData = [...localData.data] as ProcurementRfqsView[];
- newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
- setLocalData({
- ...localData,
- data: newData ?? [],
- pageCount: localData.pageCount,
- total: localData.total ?? 0
- });
- }
- }
- } finally {
- setIsProcessing(false)
- }
- }
-
- // RFQ 전송 버튼 클릭 핸들러
- const handleSendRfq = async () => {
- const rfq = getSelectedRfq()
- if (!rfq) {
- toast.warning("RFQ를 선택해주세요")
- return
- }
-
- // 전송 가능한 상태인지 확인
- if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
- toast.warning("벤더가 할당된 RFQ이거나 전송한 적이 있는 RFQ만 전송할 수 있습니다")
- return
- }
-
- try {
- setIsProcessing(true)
-
- const result = await sendRfq(rfq.id)
-
- if (result.success) {
- toast.success("RFQ가 성공적으로 전송되었습니다")
- // 데이터 리프레시
- onSuccess?.()
- } else {
- toast.error(result.message || "RFQ 전송 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("RFQ 전송 오류:", error)
- toast.error("RFQ 전송 중 오류가 발생했습니다")
- } finally {
- setIsProcessing(false)
- }
- }
-
- const handleFetchExternalRfqs = async () => {
- try {
- setIsProcessing(true);
-
- const result = await fetchExternalRfqs();
-
- if (result.success) {
- toast.success(result.message || "외부 RFQ를 성공적으로 가져왔습니다");
- // 데이터 리프레시
- onSuccess?.()
- } else {
- toast.error(result.message || "외부 RFQ를 가져오는 중 오류가 발생했습니다");
- }
- } catch (error) {
- console.error("외부 RFQ 가져오기 오류:", error);
- toast.error("외부 RFQ를 가져오는 중 오류가 발생했습니다");
- } finally {
- setIsProcessing(false);
- }
- };
-
- return (
- <>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "rfq",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- {/* RFQ 가져오기 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleFetchExternalRfqs}
- className="gap-2"
- disabled={isProcessing}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">RFQ 가져오기</span>
- </Button>
-
- {/* PR 상세보기 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleViewPrDetails}
- className="gap-2"
- disabled={!selectedRfq || !(selectedRfq.prItemsCount && selectedRfq.prItemsCount > 0)}
- >
- <ClipboardList className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">PR 상세보기</span>
- </Button>
-
- {/* RFQ 밀봉 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleSealRfq}
- className="gap-2"
- disabled={!selectedRfq || selectedRfq.rfqSealedYn === "Y" || selectedRfq.status !== "RFQ Sent" || isProcessing}
- >
- <Lock className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">RFQ 밀봉</span>
- </Button>
-
- {/* RFQ 전송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleSendRfq}
- className="gap-2"
- disabled={
- !selectedRfq ||
- (selectedRfq.status !== "RFQ Vendor Assignned" && selectedRfq.status !== "RFQ Sent") ||
- isProcessing
- }
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">RFQ 전송</span>
- </Button>
- </div>
-
- {/* PR 상세정보 다이얼로그 */}
- <PrDetailsDialog
- open={dialogOpen}
- onOpenChange={setDialogOpen}
- selectedRfq={selectedRfq}
- />
- </>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx
deleted file mode 100644
index ca976172..00000000
--- a/lib/procurement-rfqs/table/rfq-table.tsx
+++ /dev/null
@@ -1,412 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { getColumns, EditingCellState } from "./rfq-table-column"
-import { useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
-import { ProcurementRfqsView } from "@/db/schema"
-import { getPORfqs } from "../services"
-import { toast } from "sonner"
-import { updateRfqRemark } from "@/lib/procurement-rfqs/services"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { Loader2 } from "lucide-react"
-import { RFQFilterSheet } from "./rfq-filter-sheet"
-import { RfqDetailTables } from "./detail-table/rfq-detail-table"
-import { cn } from "@/lib/utils"
-
-interface RFQListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getPORfqs>>]>
- className?: string;
- calculatedHeight?: string; // 계산된 높이 추가
-}
-
-export function RFQListTable({
- promises,
- className,
- calculatedHeight
-}: RFQListTableProps) {
- const searchParams = useSearchParams()
-
- // 필터 패널 상태
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- // 선택된 RFQ 상태
- const [selectedRfq, setSelectedRfq] = React.useState<ProcurementRfqsView | null>(null)
-
- // 패널 collapse 상태
- const [isTopCollapsed, setIsTopCollapsed] = React.useState(false)
- const [panelHeight, setPanelHeight] = React.useState<number>(55)
-
- // refs
- const headerRef = React.useRef<HTMLDivElement>(null)
-
- // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
- const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
- const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
- const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
- const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
-
- // 높이 계산
- // 필터 패널 높이 - Layout Header와 Footer 사이
- const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
-
- console.log(calculatedHeight)
-
- // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
- const FIXED_TABLE_HEIGHT = calculatedHeight
- ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
- : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
-
- // Suspense 방식으로 데이터 처리
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
- const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
-
- // 초기 설정 정의
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
- from: searchParams.get('from') || undefined,
- to: searchParams.get('to') || undefined,
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: [] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- // DB 기반 프리셋 훅 사용
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- getCurrentSettings,
- } = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings)
-
- // 비고 업데이트 함수
- const updateRemark = async (rfqId: number, remark: string) => {
- try {
- const result = await updateRfqRemark(rfqId, remark);
-
- if (result.success) {
- toast.success("비고가 업데이트되었습니다");
- } else {
- toast.error(result.message || "업데이트 중 오류가 발생했습니다");
- }
- } catch (error) {
- console.error("비고 업데이트 오류:", error);
- toast.error("업데이트 중 오류가 발생했습니다");
- }
- }
-
- // 행 액션 처리
- useEffect(() => {
- if (rowAction) {
- switch (rowAction.type) {
- case "select":
- setSelectedRfq(rowAction.row.original)
- break;
- case "update":
- console.log("Update rfq:", rowAction.row.original)
- break;
- case "delete":
- console.log("Delete rfq:", rowAction.row.original)
- break;
- }
- setRowAction(null)
- }
- }, [rowAction])
-
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- editingCell,
- setEditingCell,
- updateRemark
- }),
- [setRowAction, editingCell, setEditingCell, updateRemark]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
- {
- id: "rfqCode",
- label: "RFQ No.",
- type: "text",
- },
- {
- id: "projectCode",
- label: "프로젝트",
- type: "text",
- },
- {
- id: "itemCode",
- label: "자재그룹",
- type: "text",
- },
- {
- id: "itemName",
- label: "자재명",
- type: "text",
- },
- {
- id: "rfqSealedYn",
- label: "RFQ 밀봉여부",
- type: "text",
- },
- {
- id: "majorItemMaterialCode",
- label: "자재코드",
- type: "text",
- },
- {
- id: "rfqSendDate",
- label: "RFQ 전송일",
- type: "date",
- },
- {
- id: "dueDate",
- label: "RFQ 마감일",
- type: "date",
- },
- {
- id: "createdByUserName",
- label: "요청자",
- type: "text",
- },
- ]
-
- // 현재 설정 가져오기
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- // useDataTable 초기 상태 설정
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- // useDataTable 훅 설정 (PQ와 동일한 설정)
- const { table } = useDataTable({
- data: tableData?.data || [],
- columns,
- pageCount: tableData?.pageCount || 0,
- rowCount: tableData?.total || 0,
- filterFields: [], // PQ와 동일하게 빈 배열
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false, // PQ와 동일하게 false
- clearOnDefault: true,
- })
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- // Get active basic filter count (PQ와 동일한 방식)
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams.get('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
- }
- }
-
- console.log(panelHeight)
-
- return (
- <div
- className={cn("flex flex-col relative", className)}
- style={{ height: calculatedHeight }}
- >
- {/* Filter Panel - 계산된 높이 적용 */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${LAYOUT_HEADER_HEIGHT*2}px`,
- height: FIXED_FILTER_HEIGHT
- }}
- >
- {/* Filter Content */}
- <div className="h-full">
- <RFQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content */}
- <div
- className="flex flex-col transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- height: '100%'
- }}
- >
- {/* Header Bar - 고정 높이 */}
- <div
- ref={headerRef}
- className="flex items-center justify-between p-4 bg-background border-b"
- style={{
- height: `${LOCAL_HEADER_HEIGHT}px`,
- flexShrink: 0
- }}
- >
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- {/* Right side info */}
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || 0}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area - 계산된 높이 사용 */}
- <div
- className="relative bg-background"
- style={{
- height: FIXED_TABLE_HEIGHT,
- display: 'grid',
- gridTemplateRows: '1fr',
- gridTemplateColumns: '1fr'
- }}
- >
- <ResizablePanelGroup
- direction="vertical"
- className="w-full h-full"
- >
- <ResizablePanel
- defaultSize={60}
- minSize={25}
- maxSize={75}
- collapsible={false}
- onResize={(size) => {
- setPanelHeight(size)
- }}
- className="flex flex-col overflow-hidden"
- >
- {/* 상단 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden">
- <DataTable
- table={table}
- // className="h-full"
- maxHeight={`${panelHeight*0.5}vh`}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<ProcurementRfqsView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <RFQTableToolbarActions
- table={table}
- localData={tableData}
- setLocalData={() => {}}
- onSuccess={() => {}}
- />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- minSize={25}
- defaultSize={40}
- collapsible={false}
- className="flex flex-col overflow-hidden"
- >
- {/* 하단 상세 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden bg-background">
- <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/validations.ts b/lib/procurement-rfqs/validations.ts
deleted file mode 100644
index 5059755f..00000000
--- a/lib/procurement-rfqs/validations.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,parseAsBoolean
-} from "nuqs/server"
-import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { ProcurementRfqsView, ProcurementVendorQuotations } from "@/db/schema";
-
-
-// =======================
-// 1) SearchParams (목록 필터링/정렬)
-// =======================
-export const searchParamsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<ProcurementRfqsView>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetPORfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
-
-
-export const searchParamsVendorRfqCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<ProcurementVendorQuotations>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
deleted file mode 100644
index 69ba0363..00000000
--- a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
+++ /dev/null
@@ -1,522 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X,
- User,
- Building
-} from "lucide-react"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime, formatFileSize } from "@/lib/utils"
-import { useSession } from "next-auth/react"
-import { fetchBuyerVendorComments } from "../services"
-
-// 타입 정의
-interface Comment {
- id: number;
- rfqId: number;
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string;
- isVendorComment: boolean | null; // null 허용으로 변경
- createdAt: Date;
- updatedAt: Date;
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[];
- isRead: boolean | null // null 허용으로 변경
-}
-
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null; // null 허용으로 변경
- filePath: string;
- uploadedAt: Date;
-}
-
-// 프롭스 정의
-interface BuyerCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- quotation: {
- id: number;
- rfqId: number;
- vendorId: number;
- quotationCode: string;
- rfq?: {
- rfqCode: string;
- };
- } | null;
- onSuccess?: () => void;
-}
-
-
-
-// 벤더 코멘트 전송 함수
-export function sendVendorCommentClient(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<Comment> {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 (벤더 API 경로)
- const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- // API 호출
- return fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- })
- .then(response => {
- if (!response.ok) {
- return response.text().then(text => {
- throw new Error(`API 요청 실패: ${response.status} ${text}`);
- });
- }
- return response.json();
- })
- .then(result => {
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
- return result.data.comment;
- });
-}
-
-
-export function BuyerCommunicationDrawer({
- open,
- onOpenChange,
- quotation,
- onSuccess
-}: BuyerCommunicationDrawerProps) {
- // 세션 정보
- const { data: session } = useSession();
-
- // 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && quotation) {
- loadComments();
- }
- }, [open, quotation]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 코멘트 로드 함수
- const loadComments = async () => {
- if (!quotation) return;
-
- try {
- setIsLoading(true);
-
- // API를 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
- setComments(commentsData);
-
- // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- } finally {
- setIsLoading(false);
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- if (!newComment.trim() && attachments.length === 0) return;
- if (!quotation) return;
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendVendorCommentClient({
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
- // 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType.startsWith("image/");
- const isPdf = selectedAttachment.fileType.includes("pdf");
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType)}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType)}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!quotation) {
- return null;
- }
-
- // 구매자 정보 (실제로는 API에서 가져와야 함)
- const buyerName = "구매 담당자";
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[85vh]">
- <DrawerHeader className="border-b">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{buyerName}</span>
- <Badge variant="outline" className="ml-2">구매자</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="p-0 flex flex-col h-[60vh]">
- {/* 메시지 목록 */}
- <ScrollArea className="flex-1 p-4">
- {isLoading ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
- >
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? (
- session?.user?.name || "벤더"
- ) : (
- comment.userName || buyerName
- )}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${comment.isVendorComment
- ? 'border-t border-t-primary-foreground/20'
- : 'border-t border-t-border/30'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType)}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt)}
- </div>
- </div>
-
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- <Building className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </ScrollArea>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t">
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
deleted file mode 100644
index 66bb2613..00000000
--- a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
+++ /dev/null
@@ -1,955 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useMemo } from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { format } from "date-fns"
-import { toast } from "sonner"
-import { MessageSquare, Paperclip } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { DatePicker } from "@/components/ui/date-picker"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { Skeleton } from "@/components/ui/skeleton"
-
-import { formatCurrency, formatDate } from "@/lib/utils"
-import { QuotationItemEditor } from "./quotation-item-editor"
-import {
- submitVendorQuotation,
- updateVendorQuotation,
- fetchCurrencies,
- fetchPaymentTerms,
- fetchIncoterms,
- fetchBuyerVendorComments,
- Comment
-} from "../services"
-import { BuyerCommunicationDrawer } from "./buyer-communication-drawer"
-
-// 견적서 폼 스키마
-const quotationFormSchema = z.object({
- quotationVersion: z.number().min(1),
- // 필수값 표시됨
- currency: z.string().min(1, "통화를 선택해주세요"),
- // 필수값 표시됨
- validUntil: z.date({
- required_error: "견적 유효기간을 선택해주세요",
- invalid_type_error: "유효한 날짜를 선택해주세요",
- }),
- // 필수값 표시됨
- estimatedDeliveryDate: z.date({
- required_error: "예상 납품일을 선택해주세요",
- invalid_type_error: "유효한 날짜를 선택해주세요",
- }),
- // 필수값 표시됨
- paymentTermsCode: z.string({
- required_error: "지불 조건을 선택해주세요",
- }).min(1, "지불 조건을 선택해주세요"),
- // 필수값 표시됨
- incotermsCode: z.string({
- required_error: "인코텀즈를 선택해주세요",
- }).min(1, "인코텀즈를 선택해주세요"),
- // 필수값 아님
- incotermsDetail: z.string().optional(),
- // 필수값 아님
- remark: z.string().optional(),
-})
-
-type QuotationFormValues = z.infer<typeof quotationFormSchema>
-
-// 데이터 타입 정의
-interface Currency {
- code: string
- name: string
-}
-
-interface PaymentTerm {
- code: string
- description: string
-}
-
-interface Incoterm {
- code: string
- description: string
-}
-
-// 이 컴포넌트에 전달되는 견적서 데이터 타입
-interface VendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- quotationCode: string | null
- quotationVersion: number | null
- totalItemsCount: number | null
- subTotal: string| null
- taxTotal: string| null
- discountTotal: string| null
- totalPrice: string| null
- currency: string| null
- validUntil: Date | null
- estimatedDeliveryDate: Date | null
- paymentTermsCode: string | null
- incotermsCode: string | null
- incotermsDetail: string | null
- status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"
- remark: string | null
- rejectionReason: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
- rfq: {
- id: number
- rfqCode: string| null
- dueDate: Date | null
- status: string| null
- // 기타 필요한 정보
- }
- vendor: {
- id: number
- vendorName: string
- vendorCode: string| null
- // 기타 필요한 정보
- }
- items: QuotationItem[]
-}
-
-// 견적 아이템 타입
-interface QuotationItem {
- id: number
- quotationId: number
- prItemId: number
- materialCode: string | null
- materialDescription: string | null
- quantity: number
- uom: string | null
- unitPrice: number
- totalPrice: number
- currency: string
- vendorMaterialCode: string | null
- vendorMaterialDescription: string | null
- deliveryDate: Date | null
- leadTimeInDays: number | null
- taxRate: number | null
- taxAmount: number | null
- discountRate: number | null
- discountAmount: number | null
- remark: string | null
- isAlternative: boolean
- isRecommended: boolean
- createdAt: Date
- updatedAt: Date
- prItem?: {
- id: number
- materialCode: string | null
- materialDescription: string | null
- // 기타 필요한 정보
- }
-}
-
-// 견적서 편집 컴포넌트 프롭스
-interface VendorQuotationEditorProps {
- quotation: VendorQuotation
-}
-
-export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) {
-
-
- console.log(quotation)
-
- const [activeTab, setActiveTab] = useState("items")
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [items, setItems] = useState<QuotationItem[]>(quotation.items || [])
-
- // 서버에서 가져온 데이터 상태
- const [currencies, setCurrencies] = useState<Currency[]>([])
- const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([])
- const [incoterms, setIncoterms] = useState<Incoterm[]>([])
-
- // 데이터 로딩 상태
- const [loadingCurrencies, setLoadingCurrencies] = useState(true)
- const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true)
- const [loadingIncoterms, setLoadingIncoterms] = useState(true)
-
- // 커뮤니케이션 드로어 상태
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
-
- const [comments, setComments] = useState<Comment[]>([]);
- const [unreadCount, setUnreadCount] = useState(0);
- const [loadingComments, setLoadingComments] = useState(false);
-
- // 컴포넌트 마운트 시 메시지 미리 로드
- useEffect(() => {
- if (quotation) {
- loadCommunicationData();
- }
- }, [quotation]);
-
- // 메시지 데이터 로드 함수
- const loadCommunicationData = async () => {
- try {
- setLoadingComments(true);
- const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
- setComments(commentsData);
-
- // 읽지 않은 메시지 수 계산
- const unread = commentsData.filter(
- comment => !comment.isVendorComment && !comment.isRead
- ).length;
- setUnreadCount(unread);
- } catch (error) {
- console.error("메시지 데이터 로드 오류:", error);
- } finally {
- setLoadingComments(false);
- }
- };
-
- // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
- const handleCommunicationDrawerChange = (open: boolean) => {
- setCommunicationDrawerOpen(open);
- if (!open) {
- loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
- }
- };
-
- // 버튼 비활성화
- const isBeforeDueDate = () => {
- if (!quotation.rfq.dueDate) {
- // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환)
- return false;
- }
-
- const now = new Date();
- const dueDate = new Date(quotation.rfq.dueDate);
- return now < dueDate;
- };
- // 수정된 isDisabled 조건
- const isDisabled = (quotation.status === "Accepted") ||
- ((quotation.status === "Submitted" || quotation.status === "Revised") &&
- !isBeforeDueDate());
-
-
- // 견적서 총합 계산
- const totals = useMemo(() => {
- const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0)
- const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0)
- const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0)
- const totalPrice = subTotal + taxTotal - discountTotal
-
- return {
- subTotal,
- taxTotal,
- discountTotal,
- totalPrice
- }
- }, [items])
-
- // 폼 설정
- const form = useForm<QuotationFormValues>({
- resolver: zodResolver(quotationFormSchema),
- defaultValues: {
- quotationVersion: quotation.quotationVersion || 0,
- currency: quotation.currency || "KRW",
- validUntil: quotation.validUntil || undefined,
- estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined,
- paymentTermsCode: quotation.paymentTermsCode || "",
- incotermsCode: quotation.incotermsCode || "",
- incotermsDetail: quotation.incotermsDetail || "",
- remark: quotation.remark || "",
- },
- mode: "onChange", // 실시간 검증 활성화
- })
-
- // 마운트 시 데이터 로드
- useEffect(() => {
- // 통화 데이터 로드
- const loadCurrencies = async () => {
- try {
- setLoadingCurrencies(true)
- const result = await fetchCurrencies()
- if (result.success) {
- setCurrencies(result.data)
- } else {
- toast.error(result.message || "통화 데이터 로드 실패")
- }
- } catch (error) {
- console.error("통화 데이터 로드 오류:", error)
- toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다")
- } finally {
- setLoadingCurrencies(false)
- }
- }
-
- // 지불 조건 데이터 로드
- const loadPaymentTerms = async () => {
- try {
- setLoadingPaymentTerms(true)
- const result = await fetchPaymentTerms()
- if (result.success) {
- setPaymentTerms(result.data)
- } else {
- toast.error(result.message || "지불 조건 데이터 로드 실패")
- }
- } catch (error) {
- console.error("지불 조건 데이터 로드 오류:", error)
- toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다")
- } finally {
- setLoadingPaymentTerms(false)
- }
- }
-
- // 인코텀즈 데이터 로드
- const loadIncoterms = async () => {
- try {
- setLoadingIncoterms(true)
- const result = await fetchIncoterms()
- if (result.success) {
- setIncoterms(result.data)
- } else {
- toast.error(result.message || "인코텀즈 데이터 로드 실패")
- }
- } catch (error) {
- console.error("인코텀즈 데이터 로드 오류:", error)
- toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다")
- } finally {
- setLoadingIncoterms(false)
- }
- }
-
- // 함수 호출
- loadCurrencies()
- loadPaymentTerms()
- loadIncoterms()
- }, [])
-
- // 견적서 저장
- const handleSave = async () => {
- try {
- setIsSaving(true)
-
- // 기본 검증 (통화는 필수)
- const validationResult = await form.trigger(['currency']);
- if (!validationResult) {
- toast.warning("통화는 필수 항목입니다");
- return;
- }
-
- const values = form.getValues()
-
- const result = await updateVendorQuotation({
- id: quotation.id,
- ...values,
- subTotal: totals.subTotal.toString(),
- taxTotal: totals.taxTotal.toString(),
- discountTotal: totals.discountTotal.toString(),
- totalPrice: totals.totalPrice.toString(),
- totalItemsCount: items.length,
- })
-
- if (result.success) {
- toast.success("견적서가 저장되었습니다")
-
- // 견적서 제출 준비 상태 점검
- const formValid = await form.trigger();
- const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate);
- const alternativeItemsValid = !items.some(item =>
- item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
- );
-
- if (formValid && itemsValid && alternativeItemsValid) {
- toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요.");
- } else {
- const missingFields = [];
- if (!formValid) missingFields.push("견적서 기본 정보");
- if (!itemsValid) missingFields.push("견적 항목의 단가/납품일");
- if (!alternativeItemsValid) missingFields.push("대체품 정보");
-
- toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`);
- }
- } else {
- toast.error(result.message || "견적서 저장 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("견적서 저장 오류:", error)
- toast.error("견적서 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // 견적서 제출
- const handleSubmit = async () => {
- try {
- setIsSubmitting(true)
-
- // 1. 폼 스키마 검증 (기본 정보)
- const formValid = await form.trigger();
- if (!formValid) {
- const formState = form.getFieldState("validUntil");
- const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate");
- const paymentTermsState = form.getFieldState("paymentTermsCode");
- const incotermsState = form.getFieldState("incotermsCode");
-
- // 주요 필드별 오류 메시지 표시
- if (!form.getValues("validUntil")) {
- toast.error("견적 유효기간을 선택해주세요");
- } else if (!form.getValues("estimatedDeliveryDate")) {
- toast.error("예상 납품일을 선택해주세요");
- } else if (!form.getValues("paymentTermsCode")) {
- toast.error("지불 조건을 선택해주세요");
- } else if (!form.getValues("incotermsCode")) {
- toast.error("인코텀즈를 선택해주세요");
- } else {
- toast.error("견적서 기본 정보를 모두 입력해주세요");
- }
-
- // 견적 정보 탭으로 이동
- setActiveTab("details");
- return;
- }
-
- // 2. 견적 항목 검증
- const emptyItems = items.filter(item =>
- item.unitPrice <= 0 || !item.deliveryDate
- );
-
- if (emptyItems.length > 0) {
- toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`);
- setActiveTab("items");
- return;
- }
-
- // 3. 대체품 정보 검증
- const invalidAlternativeItems = items.filter(item =>
- item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
- );
-
- if (invalidAlternativeItems.length > 0) {
- toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`);
- setActiveTab("items");
- return;
- }
-
- // 모든 검증 통과 - 제출 진행
- const values = form.getValues();
-
- const result = await submitVendorQuotation({
- id: quotation.id,
- ...values,
- subTotal: totals.subTotal.toString(),
- taxTotal: totals.taxTotal.toString(),
- discountTotal: totals.discountTotal.toString(),
- totalPrice: totals.totalPrice.toString(),
- totalItemsCount: items.length,
- });
-
- if (result.success && isBeforeDueDate()) {
- toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다.");
-
- // 페이지 새로고침
- window.location.reload();
- } else {
- toast.error(result.message || "견적서 제출 중 오류가 발생했습니다");
- }
- } catch (error) {
- console.error("견적서 제출 오류:", error);
- toast.error("견적서 제출 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- }
-
- const isSubmitReady = () => {
- // 폼 유효성
- const formValid = !Object.keys(form.formState.errors).length;
-
- // 항목 유효성
- const itemsValid = !items.some(item =>
- item.unitPrice <= 0 || !item.deliveryDate
- );
-
- // 대체품 유효성
- const alternativeItemsValid = !items.some(item =>
- item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
- );
-
- // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가
- return formValid && itemsValid && alternativeItemsValid;
- }
-
- // 아이템 업데이트 핸들러
- const handleItemsUpdate = (updatedItems: QuotationItem[]) => {
- setItems(updatedItems)
- }
-
- // 상태에 따른 배지 색상
- const getStatusBadge = (status: string) => {
- switch (status) {
- case "Draft":
- return <Badge variant="outline">초안</Badge>
- case "Submitted":
- return <Badge variant="default">제출됨</Badge>
- case "Revised":
- return <Badge variant="secondary">수정됨</Badge>
- case "Rejected":
- return <Badge variant="destructive">반려됨</Badge>
- case "Accepted":
- return <Badge variant="default">승인됨</Badge>
- default:
- return <Badge>{status}</Badge>
- }
- }
-
- // 셀렉트 로딩 상태 표시 컴포넌트
- const SelectSkeleton = () => (
- <div className="flex flex-col gap-2">
- <Skeleton className="h-4 w-[40%]" />
- <Skeleton className="h-10 w-full" />
- </div>
- )
-
- return (
- <div className="space-y-6">
- <div className="flex justify-between items-start">
- <div>
- <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1>
- <p className="text-muted-foreground">
- RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode}
- </p>
- {quotation.rfq.dueDate ? (
- <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}>
- 마감일: {formatDate(new Date(quotation.rfq.dueDate))}
- {isBeforeDueDate()
- ? ' (마감 전: 수정 가능)'
- : ' (마감 됨: 수정 불가)'}
- </p>
- ) : (
- <p className="text-sm text-amber-600">
- 마감일이 설정되지 않았습니다
- </p>
- )}
- </div>
- <div className="flex items-center gap-2">
- {getStatusBadge(quotation.status)}
- {quotation.status === "Rejected" && (
- <div className="text-sm text-destructive">
- <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"}
- </div>
- )}
- </div>
- </div>
-
- <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
- <TabsList>
- <TabsTrigger value="items">견적 항목</TabsTrigger>
- <TabsTrigger value="details">견적 정보</TabsTrigger>
- <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
- </TabsList>
-
- {/* 견적 항목 탭 */}
- <TabsContent value="items" className="p-0 pt-4">
- <Card>
- <CardHeader>
- <CardTitle>견적 항목 정보</CardTitle>
- <CardDescription>
- 각 항목에 대한 가격, 납품일 등을 입력해주세요
- </CardDescription>
- </CardHeader>
- <CardContent>
- <QuotationItemEditor
- items={items}
- onItemsChange={handleItemsUpdate}
- disabled={isDisabled}
- currency={form.watch("currency")}
- />
- </CardContent>
- <CardFooter className="flex justify-between border-t p-4">
- <div className="space-y-1">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)}
- </div>
- <div className="text-sm text-muted-foreground">
- <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)}
- </div>
- <div className="text-sm text-muted-foreground">
- <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)}
- </div>
- <div className="text-base font-bold">
- <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)}
- </div>
- </div>
- <div className="flex space-x-2">
- <Button
- variant="outline"
- onClick={handleSave}
- disabled={isDisabled || isSaving}
- >
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isDisabled || isSubmitting || !isSubmitReady()}
- >
- {isSubmitting ? "제출 중..." : "견적서 제출"}
- </Button>
- </div>
- </CardFooter>
- </Card>
- </TabsContent>
-
- {/* 견적 정보 탭 */}
- <TabsContent value="details" className="p-0 pt-4">
- <Form {...form}>
- <form className="space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>견적서 기본 정보</CardTitle>
- <CardDescription>
- 견적서의 일반 정보를 입력해주세요
- </CardDescription>
- </CardHeader>
- <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
- {/* 통화 필드 */}
- {loadingCurrencies ? (
- <SelectSkeleton />
- ) : (
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 통화
- <span className="text-destructive ml-1">*</span>
- </FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isDisabled}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.code} ({currency.name})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- <FormField
- control={form.control}
- name="validUntil"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 견적 유효기간
- <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */}
- </FormLabel>
- <FormControl>
- <DatePicker
- date={field.value}
- onSelect={field.onChange}
- disabled={isDisabled}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="estimatedDeliveryDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 예상 납품일
- <span className="text-destructive ml-1">*</span>
- </FormLabel>
- <FormControl>
- <DatePicker
- date={field.value}
- onSelect={field.onChange}
- disabled={isDisabled}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 지불 조건 필드 */}
- {loadingPaymentTerms ? (
- <SelectSkeleton />
- ) : (
- <FormField
- control={form.control}
- name="paymentTermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 지불 조건
- <span className="text-destructive ml-1">*</span>
- </FormLabel>
- <Select
- value={field.value || ""}
- onValueChange={field.onChange}
- disabled={isDisabled}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="지불 조건 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {paymentTerms.map((term) => (
- <SelectItem key={term.code} value={term.code}>
- {term.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 인코텀즈 필드 */}
- {loadingIncoterms ? (
- <SelectSkeleton />
- ) : (
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 인코텀즈
- <span className="text-destructive ml-1">*</span>
- </FormLabel>
- <Select
- value={field.value || ""}
- onValueChange={field.onChange}
- disabled={isDisabled}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {incoterms.map((term) => (
- <SelectItem key={term.code} value={term.code}>
- {term.code} ({term.description})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- <FormField
- control={form.control}
- name="incotermsDetail"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center">
- 인코텀즈 상세
- <span className="text-destructive ml-1"></span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="인코텀즈 상세 정보 입력"
- {...field}
- value={field.value || ""}
- disabled={isDisabled}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="remark"
- render={({ field }) => (
- <FormItem className="col-span-2">
- <FormLabel className="flex items-center">
- 비고
- <span className="text-destructive ml-1"></span>
- </FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 정보나 특이사항을 입력해주세요"
- className="resize-none min-h-[100px]"
- {...field}
- value={field.value || ""}
- disabled={isDisabled}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- <CardFooter className="flex justify-end">
- <Button
- variant="outline"
- onClick={handleSave}
- disabled={isDisabled || isSaving}
- >
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- </CardFooter>
- </Card>
- </form>
- </Form>
- </TabsContent>
-
- {/* 커뮤니케이션 탭 */}
- <TabsContent value="communication" className="p-0 pt-4">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle className="flex items-center gap-2">
- 커뮤니케이션
- {unreadCount > 0 && (
- <Badge variant="destructive" className="ml-2">
- 새 메시지 {unreadCount}
- </Badge>
- )}
- </CardTitle>
- <CardDescription>
- 구매자와의 메시지 및 첨부파일
- </CardDescription>
- </div>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- variant="outline"
- size="sm"
- >
- <MessageSquare className="h-4 w-4 mr-2" />
- {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
- </Button>
- </CardHeader>
- <CardContent>
- {loadingComments ? (
- <div className="flex items-center justify-center p-8">
- <div className="text-center">
- <Skeleton className="h-4 w-32 mx-auto mb-2" />
- <Skeleton className="h-4 w-48 mx-auto" />
- </div>
- </div>
- ) : comments.length === 0 ? (
- <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
- <div className="max-w-md">
- <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
- <MessageSquare className="h-6 w-6 text-primary" />
- </div>
- <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
- <p className="text-muted-foreground mb-4">
- 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
- </p>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="mx-auto"
- >
- 메시지 보내기
- </Button>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 최근 메시지 3개 미리보기 */}
- <div className="space-y-2">
- <h3 className="text-sm font-medium">최근 메시지</h3>
- <ScrollArea className="h-[250px] rounded-md border p-4">
- {comments.slice(-3).map(comment => (
- <div
- key={comment.id}
- className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
- ? 'bg-primary/10 border-l-4 border-primary'
- : 'bg-muted/50'
- }`}
- >
- <div className="flex justify-between items-center mb-1">
- <span className="text-sm font-medium">
- {comment.isVendorComment
- ? '나'
- : comment.userName || '구매 담당자'}
- </span>
- <span className="text-xs text-muted-foreground">
- {new Date(comment.createdAt).toLocaleDateString()}
- </span>
- </div>
- <p className="text-sm line-clamp-2">{comment.content}</p>
- {comment.attachments.length > 0 && (
- <div className="mt-1 text-xs text-muted-foreground">
- <Paperclip className="h-3 w-3 inline mr-1" />
- 첨부파일 {comment.attachments.length}개
- </div>
- )}
- </div>
- ))}
- </ScrollArea>
- </div>
-
- <div className="flex justify-center">
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="w-full"
- >
- 전체 메시지 보기 ({comments.length}개)
- </Button>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 커뮤니케이션 드로어 */}
- <BuyerCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={handleCommunicationDrawerChange}
- quotation={quotation}
- onSuccess={loadCommunicationData}
- />
- </TabsContent>
- </Tabs>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
deleted file mode 100644
index e11864dc..00000000
--- a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
+++ /dev/null
@@ -1,664 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import { format } from "date-fns"
-
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DatePicker } from "@/components/ui/date-picker"
-import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@/components/ui/table"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger
-} from "@/components/ui/tooltip"
-import {
- Info,
- Clock,
- CalendarIcon,
- ClipboardCheck,
- AlertTriangle,
- CheckCircle2,
- RefreshCw,
- Save,
- FileText,
- Sparkles
-} from "lucide-react"
-
-import { formatCurrency } from "@/lib/utils"
-import { updateQuotationItem } from "../services"
-import { Textarea } from "@/components/ui/textarea"
-
-// 견적 아이템 타입
-interface QuotationItem {
- id: number
- quotationId: number
- prItemId: number
- materialCode: string | null
- materialDescription: string | null
- quantity: number
- uom: string | null
- unitPrice: number
- totalPrice: number
- currency: string
- vendorMaterialCode: string | null
- vendorMaterialDescription: string | null
- deliveryDate: Date | null
- leadTimeInDays: number | null
- taxRate: number | null
- taxAmount: number | null
- discountRate: number | null
- discountAmount: number | null
- remark: string | null
- isAlternative: boolean
- isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음
- createdAt: Date
- updatedAt: Date
- prItem?: {
- id: number
- materialCode: string | null
- materialDescription: string | null
- // 기타 필요한 정보
- }
-}
-
-// debounce 함수 구현
-function debounce<T extends (...args: any[]) => any>(
- func: T,
- wait: number
-): (...args: Parameters<T>) => void {
- let timeout: NodeJS.Timeout | null = null;
-
- return function (...args: Parameters<T>) {
- if (timeout) clearTimeout(timeout);
- timeout = setTimeout(() => func(...args), wait);
- };
-}
-
-interface QuotationItemEditorProps {
- items: QuotationItem[]
- onItemsChange: (items: QuotationItem[]) => void
- disabled?: boolean
- currency: string
-}
-
-export function QuotationItemEditor({
- items,
- onItemsChange,
- disabled = false,
- currency
-}: QuotationItemEditorProps) {
- const [editingItem, setEditingItem] = useState<number | null>(null)
- const [isSaving, setIsSaving] = useState(false)
-
- // 저장이 필요한 항목들을 추적
- const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set())
-
- // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음
- const updateLocalItem = <K extends keyof QuotationItem>(
- index: number,
- field: K,
- value: QuotationItem[K]
- ) => {
- // 로컬 상태 업데이트
- const updatedItems = [...items]
- const item = { ...updatedItems[index] }
-
- // 필드 업데이트
- item[field] = value
-
- // 대체품 체크 해제 시 관련 필드 초기화
- if (field === 'isAlternative' && value === false) {
- item.vendorMaterialCode = null;
- item.vendorMaterialDescription = null;
- item.remark = null;
- }
-
- // 단가나 수량이 변경되면 총액 계산
- if (field === 'unitPrice' || field === 'quantity') {
- item.totalPrice = Number(item.unitPrice) * Number(item.quantity)
-
- // 세금이 있으면 세액 계산
- if (item.taxRate) {
- item.taxAmount = item.totalPrice * (item.taxRate / 100)
- }
-
- // 할인이 있으면 할인액 계산
- if (item.discountRate) {
- item.discountAmount = item.totalPrice * (item.discountRate / 100)
- }
- }
-
- // 세율이 변경되면 세액 계산
- if (field === 'taxRate') {
- item.taxAmount = item.totalPrice * (value as number / 100)
- }
-
- // 할인율이 변경되면 할인액 계산
- if (field === 'discountRate') {
- item.discountAmount = item.totalPrice * (value as number / 100)
- }
-
- // 변경된 아이템으로 교체
- updatedItems[index] = item
-
- // 미저장 항목으로 표시
- setPendingChanges(prev => new Set(prev).add(item.id))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- // 저장 필요함을 표시
- return item
- }
-
- // 서버에 저장하는 함수
- const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- if (disabled) return
-
- try {
- setIsSaving(true)
-
- const result = await updateQuotationItem({
- id: item.id,
- [field]: value,
- totalPrice: item.totalPrice,
- taxAmount: item.taxAmount ?? 0,
- discountAmount: item.discountAmount ?? 0
- })
-
- // 저장 완료 후 pendingChanges에서 제거
- setPendingChanges(prev => {
- const newSet = new Set(prev)
- newSet.delete(item.id)
- return newSet
- })
-
- if (!result.success) {
- toast.error(result.message || "항목 저장 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("항목 저장 오류:", error)
- toast.error("항목 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // debounce된 저장 함수
- const debouncedSave = useRef(debounce(
- (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- saveItemToServer(item, field, value)
- },
- 800 // 800ms 지연
- )).current
-
- // 견적 항목 업데이트 함수
- const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => {
- const updatedItem = updateLocalItem(index, field, value)
-
- // debounce를 통해 서버 저장 지연
- if (!disabled) {
- debouncedSave(updatedItem, field, value)
- }
- }
-
- // 모든 변경 사항 저장
- const saveAllChanges = async () => {
- if (disabled || pendingChanges.size === 0) return
-
- setIsSaving(true)
- toast.info(`${pendingChanges.size}개 항목 저장 중...`)
-
- try {
- // 변경된 모든 항목 저장
- for (const itemId of pendingChanges) {
- const index = items.findIndex(item => item.id === itemId)
- if (index !== -1) {
- const item = items[index]
- await updateQuotationItem({
- id: item.id,
- unitPrice: item.unitPrice,
- totalPrice: item.totalPrice,
- taxRate: item.taxRate ?? 0,
- taxAmount: item.taxAmount ?? 0,
- discountRate: item.discountRate ?? 0,
- discountAmount: item.discountAmount ?? 0,
- deliveryDate: item.deliveryDate,
- leadTimeInDays: item.leadTimeInDays ?? 0,
- vendorMaterialCode: item.vendorMaterialCode ?? "",
- vendorMaterialDescription: item.vendorMaterialDescription ?? "",
- isAlternative: item.isAlternative,
- isRecommended: false, // 항상 false로 설정 (사용하지 않음)
- remark: item.remark ?? ""
- })
- }
- }
-
- // 모든 변경 사항 저장 완료
- setPendingChanges(new Set())
- toast.success("모든 변경 사항이 저장되었습니다")
- } catch (error) {
- console.error("변경 사항 저장 오류:", error)
- toast.error("변경 사항 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후)
- const handleBlur = (index: number, field: keyof QuotationItem, value: any) => {
- const itemId = items[index].id
-
- // 해당 항목이 pendingChanges에 있다면 즉시 저장
- if (pendingChanges.has(itemId)) {
- const item = items[index]
- saveItemToServer(item, field, value)
- }
- }
-
- // 전체 단가 업데이트 (일괄 반영)
- const handleBulkUnitPriceUpdate = () => {
- if (items.length === 0) return
-
- // 첫 번째 아이템의 단가 가져오기
- const firstUnitPrice = items[0].unitPrice
-
- if (!firstUnitPrice) {
- toast.error("첫 번째 항목의 단가를 먼저 입력해주세요")
- return
- }
-
- // 모든 아이템에 동일한 단가 적용
- const updatedItems = items.map(item => ({
- ...item,
- unitPrice: firstUnitPrice,
- totalPrice: firstUnitPrice * item.quantity,
- taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount,
- discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount
- }))
-
- // 모든 아이템을 변경 필요 항목으로 표시
- setPendingChanges(new Set(updatedItems.map(item => item.id)))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.")
- }
-
- // 입력 핸들러
- const handleNumberInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement>
- ) => {
- const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
- handleItemUpdate(index, field, value)
- }
-
- const handleTextInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
- ) => {
- handleItemUpdate(index, field, e.target.value)
- }
-
- const handleDateChange = (
- index: number,
- field: keyof QuotationItem,
- date: Date | undefined
- ) => {
- handleItemUpdate(index, field, date || null)
- }
-
- const handleCheckboxChange = (
- index: number,
- field: keyof QuotationItem,
- checked: boolean
- ) => {
- handleItemUpdate(index, field, checked)
- }
-
- // 날짜 형식 지정
- const formatDeliveryDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy-MM-dd")
- }
-
- // 입력 폼 필드 렌더링
- const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => {
- if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') {
- return (
- <Input
- type="number"
- min={0}
- step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1}
- value={item[field] as number || 0}
- onChange={(e) => handleNumberInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)}
- disabled={disabled || isSaving}
- className="w-full"
- />
- )
- } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') {
- return (
- <Input
- type="text"
- value={item[field] as string || ''}
- onChange={(e) => handleTextInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, e.target.value)}
- disabled={disabled || isSaving || !item.isAlternative}
- className="w-full"
- placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"}
- />
- )
- } else if (field === 'deliveryDate') {
- return (
- <DatePicker
- date={item.deliveryDate ? new Date(item.deliveryDate) : undefined}
- onSelect={(date) => {
- handleDateChange(index, field, date);
- // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거
- if (date) handleBlur(index, field, date);
- }}
- disabled={disabled || isSaving}
- />
- )
- } else if (field === 'isAlternative') {
- return (
- <div className="flex items-center gap-1">
- <Checkbox
- checked={item.isAlternative}
- onCheckedChange={(checked) => {
- handleCheckboxChange(index, field, checked as boolean);
- handleBlur(index, field, checked as boolean);
- }}
- disabled={disabled || isSaving}
- />
- <span className="text-xs">대체품</span>
- </div>
- )
- }
-
- return null
- }
-
- // 대체품 필드 렌더링
- const renderAlternativeFields = (item: QuotationItem, index: number) => {
- if (!item.isAlternative) return null;
-
- return (
- <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm">
- {/* <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재코드</label>
- <Input
- value={item.vendorMaterialCode || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재코드 입력"
- />
- </div> */}
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재명</label>
- <Input
- value={item.vendorMaterialDescription || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재명 입력"
- />
- </div>
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">대체품 설명</label>
- <Textarea
- value={item.remark || ""}
- onChange={(e) => handleTextInputChange(index, 'remark', e)}
- onBlur={(e) => handleBlur(index, 'remark', e.target.value)}
- disabled={disabled || isSaving}
- className="min-h-[60px] text-sm"
- placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요"
- />
- </div>
- </div>
- );
- };
-
- // 항목의 저장 상태 아이콘 표시
- const renderSaveStatus = (itemId: number) => {
- if (pendingChanges.has(itemId)) {
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" />
- </TooltipTrigger>
- <TooltipContent>
- <p>저장되지 않은 변경 사항이 있습니다</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- }
-
- return null
- }
-
- return (
- <div className="space-y-4">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3>
- {pendingChanges.size > 0 && (
- <Badge variant="outline" className="bg-yellow-50">
- 변경 {pendingChanges.size}개
- </Badge>
- )}
- </div>
-
- <div className="flex items-center gap-2">
- {pendingChanges.size > 0 && !disabled && (
- <Button
- variant="default"
- size="sm"
- onClick={saveAllChanges}
- disabled={isSaving}
- >
- {isSaving ? (
- <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Save className="h-4 w-4 mr-2" />
- )}
- 변경사항 저장 ({pendingChanges.size}개)
- </Button>
- )}
-
- {!disabled && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkUnitPriceUpdate}
- disabled={items.length === 0 || isSaving}
- >
- 첫 항목 단가로 일괄 적용
- </Button>
- )}
- </div>
- </div>
-
- <ScrollArea className="h-[500px] rounded-md border">
- <Table>
- <TableHeader className="sticky top-0 bg-background">
- <TableRow>
- <TableHead className="w-[50px]">번호</TableHead>
- <TableHead>자재코드</TableHead>
- <TableHead>자재명</TableHead>
- <TableHead>수량</TableHead>
- <TableHead>단위</TableHead>
- <TableHead>단가</TableHead>
- <TableHead>금액</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 세율(%)
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 납품일
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>납품 가능한 날짜를 선택해주세요.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>리드타임(일)</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 대체품
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p>
- <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead className="w-[50px]">상태</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {items.length === 0 ? (
- <TableRow>
- <TableCell colSpan={12} className="text-center py-10">
- 견적 항목이 없습니다
- </TableCell>
- </TableRow>
- ) : (
- items.map((item, index) => (
- <React.Fragment key={item.id}>
- <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}>
- <TableCell>
- {index + 1}
- </TableCell>
- <TableCell>
- {item.materialCode || "-"}
- </TableCell>
- <TableCell>
- <div className="font-medium max-w-xs truncate">
- {item.materialDescription || "-"}
- </div>
- </TableCell>
- <TableCell>
- {item.quantity}
- </TableCell>
- <TableCell>
- {item.uom || "-"}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'unitPrice')}
- </TableCell>
- <TableCell>
- {formatCurrency(item.totalPrice, currency)}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'taxRate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'deliveryDate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'leadTimeInDays')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'isAlternative')}
- </TableCell>
- <TableCell>
- {renderSaveStatus(item.id)}
- </TableCell>
- </TableRow>
-
- {/* 대체품으로 선택된 경우 추가 정보 행 표시 */}
- {item.isAlternative && (
- <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}>
- <TableCell colSpan={1}></TableCell>
- <TableCell colSpan={10}>
- {renderAlternativeFields(item, index)}
- </TableCell>
- <TableCell colSpan={1}></TableCell>
- </TableRow>
- )}
- </React.Fragment>
- ))
- )}
- </TableBody>
- </Table>
- </ScrollArea>
-
- {isSaving && (
- <div className="flex items-center justify-center text-sm text-muted-foreground">
- <Clock className="h-4 w-4 animate-spin mr-2" />
- 변경 사항을 저장 중입니다...
- </div>
- )}
-
- <div className="bg-muted p-4 rounded-md">
- <h4 className="text-sm font-medium mb-2">안내 사항</h4>
- <ul className="text-sm space-y-1 text-muted-foreground">
- <li className="flex items-start gap-2">
- <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>단가와 납품일은 필수로 입력해야 합니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span>
- </li>
- </ul>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
deleted file mode 100644
index 1fb225d8..00000000
--- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, FileText, Pencil, Edit, Trash2 } from "lucide-react"
-import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import Link from "next/link"
-import { ProcurementVendorQuotations } from "@/db/schema"
-import { useRouter } from "next/navigation"
-
-// 상태에 따른 배지 컴포넌트
-function StatusBadge({ status }: { status: string }) {
- switch (status) {
- case "Draft":
- return <Badge variant="outline">초안</Badge>
- case "Submitted":
- return <Badge variant="default">제출됨</Badge>
- case "Revised":
- return <Badge variant="secondary">수정됨</Badge>
- case "Rejected":
- return <Badge variant="destructive">반려됨</Badge>
- case "Accepted":
- return <Badge variant="default">승인됨</Badge>
- default:
- return <Badge>{status}</Badge>
- }
-}
-
-interface QuotationWithRfqCode extends ProcurementVendorQuotations {
- rfqCode?: string;
- rfq?: {
- id?: number;
- rfqCode?: string;
- status?: string;
- dueDate?: Date | string | null;
- rfqSendDate?: Date | string | null;
- item?: {
- id?: number;
- itemCode?: string;
- itemName?: string;
- } | null;
- } | null;
- vendor?: {
- id?: number;
- vendorName?: string;
- vendorCode?: string;
- } | null;
-}
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null>
- >
- router: NextRouter
-}
-
-/**
- * tanstack table 컬럼 정의 (RfqsTable 스타일)
- */
-export function getColumns({
- setRowAction,
- router,
-}: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<QuotationWithRfqCode> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<QuotationWithRfqCode> = {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const id = row.original.id
- const code = row.getValue("quotationCode") as string
- const tooltipText = `${code} 작성하기`
-
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- onClick={() => router.push(`/partners/rfq-ship/${id}`)}
- className="h-8 w-8"
- >
- <Edit className="h-4 w-4" />
- <span className="sr-only">견적서 작성</span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{tooltipText}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- },
- size: 50,
- }
-
- // ----------------------------------------------------------------
- // 3) 컬럼 정의 배열
- // ----------------------------------------------------------------
- const columnDefinitions = [
- {
- id: "quotationCode",
- label: "RFQ 번호",
- group: null,
- size: 150,
- minSize: 100,
- maxSize: 200,
- },
- {
- id: "quotationVersion",
- label: "RFQ 버전",
- group: null,
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "itemCode",
- label: "자재 그룹 코드",
- group: "RFQ 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- {
- id: "itemName",
- label: "자재 이름",
- group: "RFQ 정보",
- // size를 제거하여 유연한 크기 조정 허용
- minSize: 150,
- maxSize: 300,
- },
- {
- id: "rfqSendDate",
- label: "RFQ 송부일",
- group: "날짜 정보",
- size: 150,
- minSize: 120,
- maxSize: 180,
- },
- {
- id: "dueDate",
- label: "RFQ 마감일",
- group: "날짜 정보",
- size: 150,
- minSize: 120,
- maxSize: 180,
- },
- {
- id: "status",
- label: "상태",
- group: null,
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "totalPrice",
- label: "총액",
- group: null,
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- {
- id: "submittedAt",
- label: "제출일",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- {
- id: "validUntil",
- label: "유효기간",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- ];
-
- // ----------------------------------------------------------------
- // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성)
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<QuotationWithRfqCode>[]> = {}
-
- columnDefinitions.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // 개별 컬럼 정의
- const columnDef: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- cell: ({ row, cell }) => {
- // 각 컬럼별 특별한 렌더링 처리
- switch (cfg.id) {
- case "quotationCode":
- return row.original.quotationCode || "-"
-
- case "quotationVersion":
- return row.original.quotationVersion || "-"
-
- case "itemCode":
- const itemCode = row.original.rfq?.item?.itemCode;
- return itemCode ? itemCode : "-";
-
- case "itemName":
- const itemName = row.original.rfq?.item?.itemName;
- return itemName ? itemName : "-";
-
- case "rfqSendDate":
- const sendDate = row.original.rfq?.rfqSendDate;
- return sendDate ? formatDateTime(new Date(sendDate)) : "-";
-
- case "dueDate":
- const dueDate = row.original.rfq?.dueDate;
- return dueDate ? formatDateTime(new Date(dueDate)) : "-";
-
- case "status":
- return <StatusBadge status={row.getValue("status") as string} />
-
- case "totalPrice":
- const price = parseFloat(row.getValue("totalPrice") as string || "0")
- const currency = row.original.currency
- return formatCurrency(price, currency)
-
- case "submittedAt":
- const submitDate = row.getValue("submittedAt") as string | null
- return submitDate ? formatDate(new Date(submitDate)) : "-"
-
- case "validUntil":
- const validDate = row.getValue("validUntil") as string | null
- return validDate ? formatDate(new Date(validDate)) : "-"
-
- default:
- return row.getValue(cfg.id) ?? ""
- }
- },
- size: cfg.size,
- minSize: cfg.minSize,
- maxSize: cfg.maxSize,
- }
-
- groupMap[groupName].push(columnDef)
- })
-
- // ----------------------------------------------------------------
- // 5) 그룹별 중첩 컬럼 생성
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<QuotationWithRfqCode>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹이 없는 컬럼들은 직접 추가
- nestedColumns.push(...colDefs)
- } else {
- // 그룹이 있는 컬럼들은 중첩 구조로 추가
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
deleted file mode 100644
index 7ea0c69e..00000000
--- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-// lib/vendor-quotations/vendor-quotations-table.tsx
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { Button } from "@/components/ui/button"
-import { ProcurementVendorQuotations } from "@/db/schema"
-import { useRouter } from "next/navigation"
-import { getColumns } from "./vendor-quotations-table-columns"
-
-interface QuotationWithRfqCode extends ProcurementVendorQuotations {
- rfqCode?: string;
- rfq?: {
- rfqCode?: string;
- } | null;
-}
-
-interface VendorQuotationsTableProps {
- promises: Promise<[{ data: ProcurementVendorQuotations[], pageCount: number }]>;
-}
-
-export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
- const [{ data, pageCount }] = React.use(promises);
- const router = useRouter();
-
- console.log(data ,"data")
-
- // 선택된 행 액션 상태
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<QuotationWithRfqCode> | null>(null);
-
- // 테이블 컬럼 정의
- const columns = React.useMemo(() => getColumns({
- setRowAction,
- router,
- }), [setRowAction, router]);
-
- // 상태별 견적서 수 계산
- const statusCounts = React.useMemo(() => {
- return {
- Draft: data.filter(q => q.status === "Draft").length,
- Submitted: data.filter(q => q.status === "Submitted").length,
- Revised: data.filter(q => q.status === "Revised").length,
- Rejected: data.filter(q => q.status === "Rejected").length,
- Accepted: data.filter(q => q.status === "Accepted").length,
- };
- }, [data]);
-
- // 필터 필드
- const filterFields: DataTableFilterField<QuotationWithRfqCode>[] = [
- {
- id: "status",
- label: "상태",
- options: [
- { label: "초안", value: "Draft", count: statusCounts.Draft },
- { label: "제출됨", value: "Submitted", count: statusCounts.Submitted },
- { label: "수정됨", value: "Revised", count: statusCounts.Revised },
- { label: "반려됨", value: "Rejected", count: statusCounts.Rejected },
- { label: "승인됨", value: "Accepted", count: statusCounts.Accepted },
- ]
- },
- {
- id: "quotationCode",
- label: "견적서 번호",
- placeholder: "견적서 번호 검색...",
- },
- {
- id: "rfqCode",
- label: "RFQ 번호",
- placeholder: "RFQ 번호 검색...",
- }
- ];
-
- // 고급 필터 필드
- const advancedFilterFields: DataTableAdvancedFilterField<QuotationWithRfqCode>[] = [
- {
- id: "quotationCode",
- label: "견적서 번호",
- type: "text",
- },
- {
- id: "rfqCode",
- label: "RFQ 번호",
- type: "text",
- },
- {
- id: "status",
- label: "상태",
- type: "multi-select",
- options: [
- { label: "초안", value: "Draft" },
- { label: "제출됨", value: "Submitted" },
- { label: "수정됨", value: "Revised" },
- { label: "반려됨", value: "Rejected" },
- { label: "승인됨", value: "Accepted" },
- ],
- },
- {
- id: "validUntil",
- label: "유효기간",
- type: "date",
- },
- {
- id: "submittedAt",
- label: "제출일",
- type: "date",
- },
- ];
-
- // useDataTable 훅 사용 (RfqsTable 스타일로 개선)
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableColumnResizing: true, // 컬럼 크기 조정 허용
- columnResizeMode: 'onChange', // 실시간 크기 조정
- initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- defaultColumn: {
- minSize: 50,
- maxSize: 500,
- },
- });
-
- return (
- <div className="w-full">
- <div className="overflow-x-auto">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- );
-} \ No newline at end of file
diff --git a/lib/projects/service.ts b/lib/projects/service.ts
index aad1856e..ba6e730a 100644
--- a/lib/projects/service.ts
+++ b/lib/projects/service.ts
@@ -1,13 +1,11 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { revalidateTag, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-import { unstable_cache } from "@/lib/unstable-cache";
+import db from "@/db/db";
+import { projects, type Project } from "@/db/schema";
import { filterColumns } from "@/lib/filter-columns";
-import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { and, asc, desc, eq, ilike, or } from "drizzle-orm";
import { countProjectLists, selectProjectLists } from "./repository";
-import { projects } from "@/db/schema";
import { GetProjectListsSchema } from "./validation";
export async function getProjectLists(input: GetProjectListsSchema) {
@@ -132,4 +130,29 @@ export async function getProjectCode(projectId: number): Promise<string | null>
console.error("Error fetching project code:", error)
return null
}
-} \ No newline at end of file
+}
+
+export async function getProjects(): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 모든 프로젝트 조회
+ const results = await tx
+ .select({
+ id: projects.id,
+ projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정
+ projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정
+ type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정
+ })
+ .from(projects)
+ .orderBy(projects.code);
+
+ return results;
+ });
+
+ return projectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx
deleted file mode 100644
index aa244c75..00000000
--- a/lib/rfqs/cbe-table/cbe-table-columns.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Ellipsis, MessageSquare } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-
-import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
- >
- router: NextRouter
- openCommentSheet: (responseId: number) => void
- openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처
-
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- router,
- openCommentSheet,
- openVendorContactsDialog
-}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorWithCbeFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
-
- vendorCbeColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<VendorWithCbeFields>
- const childCol: ColumnDef<VendorWithCbeFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
- if (cfg.id === "vendorName") {
- const vendor = row.original;
- const vendorId = vendor.vendorId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (vendorId) {
- openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "responseStatus") {
- const statusVal = row.original.responseStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
- // 예) CBE Updated (날짜)
- if (cfg.id === "respondedAt" ) {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal, "KR")
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
-// ----------------------------------------------------------------
-// 3) Comments 컬럼
-// ----------------------------------------------------------------
-const commentsColumn: ColumnDef<VendorWithCbeFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // rowAction + openCommentSheet
- setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.responseId ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize:80
-}
-
-
-
-
-// ----------------------------------------------------------------
-// 5) 최종 컬럼 배열 - Update to include the files column
-// ----------------------------------------------------------------
-return [
- selectColumn,
- ...nestedColumns,
- commentsColumn,
- // actionsColumn,
-]
-
-} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx
deleted file mode 100644
index fbcf9af9..00000000
--- a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-
-
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<VendorWithCbeFields>
- rfqId: number
-}
-
-export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일이 선택되었을 때 처리
-
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
- }
-
- const invitationPossibeVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original)
- .filter(vendor => vendor.commercialResponseStatus === null);
- }, [table.getFilteredSelectedRowModel().rows]);
-
- return (
- <div className="flex items-center gap-2">
- {invitationPossibeVendors.length > 0 &&
- (
- <InviteVendorsDialog
- vendors={invitationPossibeVendors}
- rfqId={rfqId}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- )
- }
-
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx
deleted file mode 100644
index 37fbc3f4..00000000
--- a/lib/rfqs/cbe-table/cbe-table.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service"
-import { getColumns } from "./cbe-table-columns"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { CommentSheet, CbeComment } from "./comments-sheet"
-import { useSession } from "next-auth/react" // Next-auth session hook 추가
-import { VendorContactsDialog } from "./vendor-contact-dialog"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions"
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getCBE>>,
- ]
- >
- rfqId: number
-}
-
-
-export function CbeTable({ promises, rfqId }: VendorsTableProps) {
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- const { data: session } = useSession() // 세션 정보 가져오기
-
- const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
- const currentUser = session?.user
-
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [isLoadingComments, setIsLoadingComments] = React.useState(false)
- // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
- const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
- const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
- const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null)
- // console.log("selectedVendorId", selectedVendorId)
- // console.log("selectedCbeId", selectedCbeId)
-
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.responseId))
- }
- }, [rowAction])
-
- async function openCommentSheet(responseId: number) {
- setInitialComments([])
- setIsLoadingComments(true)
- const comments = rowAction?.row.original.comments
- // const rfqId = rowAction?.row.original.rfqId
- const vendorId = rowAction?.row.original.vendorId
-
- if (comments && comments.length > 0) {
- const commentWithAttachments: CbeComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
-
- return {
- ...c,
- commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
- // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
- setInitialComments(commentWithAttachments)
- }
-
- // if(rfqId){ setSelectedRfqIdForComments(rfqId)}
- if(vendorId){ setSelectedVendorId(vendorId)}
- setSelectedCbeId(responseId)
- setCommentSheetOpen(true)
- setIsLoadingComments(false)
- }
-
- const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => {
- setSelectedVendorId(vendorId)
- setSelectedVendor(vendor)
- setIsContactDialogOpen(true)
- }
-
- // getColumns() 호출 시, router를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "respondedAt", label: "Updated at", type: "date" },
- ]
-
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "respondedAt", desc: true }],
- columnPinning: { right: ["comments"] },
- },
- getRowId: (originalRow) => String(originalRow.responseId),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable
- table={table}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <CommentSheet
- currentUserId={currentUserId}
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- rfqId={rfqId}
- cbeId={selectedCbeId ?? 0}
- vendorId={selectedVendorId ?? 0}
- isLoading={isLoadingComments}
- initialComments={initialComments}
- />
-
- <InviteVendorsDialog
- vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
- onOpenChange={() => setRowAction(null)}
- rfqId={rfqId}
- open={rowAction?.type === "invite"}
- showTrigger={false}
- currentUser={currentUser}
- />
-
- <VendorContactsDialog
- isOpen={isContactDialogOpen}
- onOpenChange={setIsContactDialogOpen}
- vendorId={selectedVendorId}
- vendor={selectedVendor}
- />
-
- </>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx
deleted file mode 100644
index b040d734..00000000
--- a/lib/rfqs/cbe-table/comments-sheet.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput,
-} from "@/components/ui/dropzone"
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell,
-} from "@/components/ui/table"
-
-import { createRfqCommentWithAttachments } from "../service"
-import { formatDate } from "@/lib/utils"
-
-
-export interface CbeComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-// 1) props 정의
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: CbeComment[]
- currentUserId: number
- rfqId: number
- // tbeId?: number
- cbeId?: number
- vendorId: number
- onCommentsUpdated?: (comments: CbeComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 2) 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional(), // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- // tbeId,
- cbeId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
-
-
- const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: [],
- },
- })
-
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles",
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> {c.createdAt ? formatDate(c.createdAt, "KR") : "-"}</TableCell>
- <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // (B) 파일 드롭
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
- // (C) Submit
- async function onSubmit(data: CommentFormValues) {
- if (!rfqId) return
- startTransition(async () => {
- try {
- // console.log("rfqId", rfqId)
- // console.log("vendorId", vendorId)
- // console.log("cbeId", cbeId)
- // console.log("currentUserId", currentUserId)
-
- const res = await createRfqCommentWithAttachments({
- rfqId,
- vendorId,
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null,
- cbeId: cbeId,
- files: data.newFiles,
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 임시로 새 코멘트 추가
- const newComment: CbeComment = {
- id: res.commentId, // 서버 응답
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments:
- data.newFiles?.map((f) => ({
- id: Math.floor(Math.random() * 1e6),
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [],
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea placeholder="Enter your comment..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div
- key={field.id}
- className="flex items-center justify-between border rounded p-2"
- >
- <span className="text-sm">
- {file.name} ({prettyBytes(file.size)})
- </span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
deleted file mode 100644
index 8d69e765..00000000
--- a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
+++ /dev/null
@@ -1,423 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Send, User } from "lucide-react"
-import { toast } from "sonner"
-import { z } from "zod"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { type Row } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import { createCbeEvaluation } from "../service"
-
-// 컴포넌트 내부에서 사용할 폼 스키마 정의
-const formSchema = z.object({
- paymentTerms: z.string().min(1, "지급 조건을 입력하세요"),
- incoterms: z.string().min(1, "Incoterms를 입력하세요"),
- deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
- notes: z.string().optional(),
-})
-
-type FormValues = z.infer<typeof formSchema>
-
-interface InviteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- vendors: Row<VendorWithCbeFields>["original"][]
- currentUserId?: number
- currentUser?: {
- id: string
- name?: string | null
- email?: string | null
- image?: string | null
- companyId?: number | null
- domain?: string | null
- }
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function InviteVendorsDialog({
- rfqId,
- vendors,
- currentUserId,
- currentUser,
- showTrigger = true,
- onSuccess,
- ...props
-}: InviteVendorsDialogProps) {
- const [files, setFiles] = React.useState<FileList | null>(null)
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // 로컬 스키마와 폼 값을 사용하도록 수정
- const form = useForm<FormValues>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- paymentTerms: "",
- incoterms: "",
- deliverySchedule: "",
- notes: "",
- },
- mode: "onChange",
- })
-
- // 폼 상태 감시
- const { formState } = form
- const isValid = formState.isValid &&
- !!form.getValues("paymentTerms") &&
- !!form.getValues("incoterms") &&
- !!form.getValues("deliverySchedule")
-
- // 디버깅용 상태 트래킹
- React.useEffect(() => {
- const subscription = form.watch((value) => {
- // 폼 값이 변경될 때마다 실행되는 콜백
- console.log("Form values changed:", value);
- });
-
- return () => subscription.unsubscribe();
- }, [form]);
-
- async function onSubmit(data: FormValues) {
- try {
- setIsSubmitting(true)
-
- // 기본 FormData 생성
- const formData = new FormData()
-
- // rfqId 추가
- formData.append("rfqId", String(rfqId))
-
- // 폼 데이터 추가
- Object.entries(data).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- formData.append(key, String(value))
- }
- })
-
- // 현재 사용자 ID 추가
- if (currentUserId) {
- formData.append("evaluatedBy", String(currentUserId))
- }
-
- // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회)
- vendors.forEach((vendor) => {
- formData.append("vendorIds[]", String(vendor.vendorId))
- })
-
- // 파일 추가 (있는 경우에만)
- if (files && files.length > 0) {
- for (let i = 0; i < files.length; i++) {
- formData.append("files", files[i])
- }
- }
-
- // 서버 액션 호출
- const response = await createCbeEvaluation(formData)
-
- if (response.error) {
- toast.error(response.error)
- return
- }
-
- // 성공 처리
- toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`)
- form.reset()
- setFiles(null)
- props.onOpenChange?.(false)
- onSuccess?.()
- } catch (error) {
- console.error(error)
- toast.error("CBE 평가 생성 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- form.reset()
- setFiles(null)
- }
- props.onOpenChange?.(nextOpen)
- }
-
- // 필수 필드 라벨에 추가할 요소
- const RequiredLabel = (
- <span className="text-destructive ml-1 font-medium">*</span>
- )
-
- const formContent = (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 선택된 협력업체 정보 표시 */}
- <div className="space-y-2">
- <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
- <ScrollArea className="h-20 border rounded-md p-2">
- <div className="flex flex-wrap gap-2">
- {vendors.map((vendor, index) => (
- <Badge key={index} variant="secondary" className="py-1">
- {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
- </Badge>
- ))}
- </div>
- </ScrollArea>
- <FormDescription>
- 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다.
- </FormDescription>
- </div>
-
- {/* 작성자 정보 (읽기 전용) */}
- {currentUser && (
- <div className="border rounded-md p-3 space-y-2">
- <FormLabel>작성자</FormLabel>
- <div className="flex items-center gap-3">
- {currentUser.image ? (
- <Avatar className="h-8 w-8">
- <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
- <AvatarFallback>
- {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
- </AvatarFallback>
- </Avatar>
- ) : (
- <Avatar className="h-8 w-8">
- <AvatarFallback>
- {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
- </AvatarFallback>
- </Avatar>
- )}
- <div>
- <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
- <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
- </div>
- </div>
- </div>
- )}
-
- {/* 지급 조건 - 필수 필드 */}
- <FormField
- control={form.control}
- name="paymentTerms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 지급 조건{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: Net 30" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Incoterms - 필수 필드 */}
- <FormField
- control={form.control}
- name="incoterms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- Incoterms{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: FOB, CIF" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 배송 일정 - 필수 필드 */}
- <FormField
- control={form.control}
- name="deliverySchedule"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 배송 일정{RequiredLabel}
- </FormLabel>
- <FormControl>
- <Textarea
- {...field}
- placeholder="배송 일정 세부사항을 입력하세요"
- rows={3}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 비고 - 선택적 필드 */}
- <FormField
- control={form.control}
- name="notes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- {...field}
- placeholder="추가 비고 사항을 입력하세요"
- rows={3}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 첨부 (옵션) */}
- <div className="space-y-2">
- <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel>
- <Input
- id="files"
- type="file"
- multiple
- onChange={(e) => setFiles(e.target.files)}
- />
- {files && files.length > 0 && (
- <p className="text-sm text-muted-foreground">
- {files.length}개 파일이 첨부되었습니다
- </p>
- )}
- </div>
-
- {/* 필수 입력 항목 안내 */}
- <div className="text-sm text-muted-foreground">
- <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다.
- </div>
-
- {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */}
- {isDesktop && (
- <DialogFooter className="gap-2 pt-4">
- <DialogClose asChild>
- <Button
- type="button"
- variant="outline"
- >
- 취소
- </Button>
- </DialogClose>
- <Button
- type="submit"
- disabled={isSubmitting || !isValid}
- >
- {isSubmitting && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
- </Button>
- </DialogFooter>
- )}
- </form>
- </Form>
- )
-
- // Desktop Dialog
- if (isDesktop) {
- return (
- <Dialog {...props} onOpenChange={handleDialogOpenChange}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- CBE 평가 전송 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>CBE 평가 생성 및 전송</DialogTitle>
- <DialogDescription>
- 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
- </DialogDescription>
- </DialogHeader>
-
- {formContent}
- </DialogContent>
- </Dialog>
- )
- }
-
- // Mobile Drawer
- return (
- <Drawer {...props} onOpenChange={handleDialogOpenChange}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- CBE 평가 전송 ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle>
- <DrawerDescription>
- 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="px-4">
- {formContent}
- </div>
-
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- onClick={form.handleSubmit(onSubmit)}
- disabled={isSubmitting || !isValid}
- >
- {isSubmitting && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx
deleted file mode 100644
index 180db392..00000000
--- a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table"
-
-interface VendorContactsDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- vendorId: number | null
- vendor: VendorWithCbeFields | null
-}
-
-export function VendorContactsDialog({
- isOpen,
- onOpenChange,
- vendorId,
- vendor,
-}: VendorContactsDialogProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
- <DialogHeader>
- <div className="flex flex-col space-y-2">
- <DialogTitle>협력업체 연락처</DialogTitle>
- {vendor && (
- <div className="flex flex-col space-y-1 mt-2">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium text-foreground">{vendor.vendorName}</span>
- {vendor.vendorCode && (
- <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
- )}
- </div>
- <div className="flex items-center">
- {vendor.vendorStatus && (
- <Badge variant="outline" className="mr-2">
- {vendor.vendorStatus}
- </Badge>
- )}
- {vendor.commercialResponseStatus && (
- <Badge
- variant={
- vendor.commercialResponseStatus === "INVITED" ? "default" :
- vendor.commercialResponseStatus === "DECLINED" ? "destructive" :
- vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline"
- }
- >
- {vendor.commercialResponseStatus}
- </Badge>
- )}
- </div>
- </div>
- )}
- </div>
- </DialogHeader>
- {vendorId && (
- <div className="py-4">
- <VendorContactsTable vendorId={vendorId} />
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts
deleted file mode 100644
index 24d09ec3..00000000
--- a/lib/rfqs/repository.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-// src/lib/tasks/repository.ts
-import db from "@/db/db";
-import { items } from "@/db/schema/items";
-import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq";
-import { users } from "@/db/schema/users";
-import {
- eq,
- inArray,
- not,
- asc,
- desc,
- and,
- ilike,
- gte,
- lte,
- count,
- gt, sql
-} from "drizzle-orm";
-import { PgTransaction } from "drizzle-orm/pg-core";
-import { RfqType } from "./validations";
-export type NewRfq = typeof rfqs.$inferInsert
-export type NewRfqItem = typeof rfqItems.$inferInsert
-
-/**
- * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
- * - 트랜잭션(tx)을 받아서 사용하도록 구현
- */
-export async function selectRfqs(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
-) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select({
- rfqId: rfqsView.id,
- id: rfqsView.id,
- rfqCode: rfqsView.rfqCode,
- description: rfqsView.description,
- projectCode: rfqsView.projectCode,
- projectName: rfqsView.projectName,
- dueDate: rfqsView.dueDate,
- status: rfqsView.status,
- // createdBy → user 이메일
- createdBy: rfqsView.createdBy, // still the numeric user ID
- createdByEmail: rfqsView.userEmail, // string
-
- createdAt: rfqsView.createdAt,
- updatedAt: rfqsView.updatedAt,
- // ====================
- // 1) itemCount via subselect
- // ====================
- itemCount:rfqsView.itemCount,
- attachCount: rfqsView.attachmentCount,
-
- // user info
- // userId: users.id,
- userEmail: rfqsView.userEmail,
- userName: rfqsView.userName,
- })
- .from(rfqsView)
- .where(where ?? undefined)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
-}
-/** 총 개수 count */
-export async function countRfqs(
- tx: PgTransaction<any, any, any>,
- where?: any
-) {
- const res = await tx.select({ count: count() }).from(rfqsView).where(where);
- return res[0]?.count ?? 0;
-}
-
-/** 단건 Insert 예시 */
-export async function insertRfq(
- tx: PgTransaction<any, any, any>,
- data: NewRfq // DB와 동일한 insert 가능한 타입
-) {
- // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
- return tx
- .insert(rfqs)
- .values(data)
- .returning({ id: rfqs.id, createdAt: rfqs.createdAt });
-}
-
-/** 복수 Insert 예시 */
-export async function insertRfqs(
- tx: PgTransaction<any, any, any>,
- data: Rfq[]
-) {
- return tx.insert(rfqs).values(data).onConflictDoNothing();
-}
-
-/** 단건 삭제 */
-export async function deleteRfqById(
- tx: PgTransaction<any, any, any>,
- rfqId: number
-) {
- return tx.delete(rfqs).where(eq(rfqs.id, rfqId));
-}
-
-/** 복수 삭제 */
-export async function deleteRfqsByIds(
- tx: PgTransaction<any, any, any>,
- ids: number[]
-) {
- return tx.delete(rfqs).where(inArray(rfqs.id, ids));
-}
-
-/** 전체 삭제 */
-export async function deleteAllRfqs(
- tx: PgTransaction<any, any, any>,
-) {
- return tx.delete(rfqs);
-}
-
-/** 단건 업데이트 */
-export async function updateRfq(
- tx: PgTransaction<any, any, any>,
- rfqId: number,
- data: Partial<Rfq>
-) {
- return tx
- .update(rfqs)
- .set(data)
- .where(eq(rfqs.id, rfqId))
- .returning({ status: rfqs.status });
-}
-
-// /** 복수 업데이트 */
-export async function updateRfqs(
- tx: PgTransaction<any, any, any>,
- ids: number[],
- data: Partial<Rfq>
-) {
- return tx
- .update(rfqs)
- .set(data)
- .where(inArray(rfqs.id, ids))
- .returning({ status: rfqs.status, dueDate: rfqs.dueDate });
-}
-
-
-// 모든 task 조회
-export const getAllRfqs = async (): Promise<Rfq[]> => {
- const datas = await db.select().from(rfqs).execute();
- return datas
-};
-
-
-export async function groupByStatus(
- tx: PgTransaction<any, any, any>,
- rfqType: RfqType = RfqType.PURCHASE
-) {
- return tx
- .select({
- status: rfqs.status,
- count: count(),
- })
- .from(rfqs)
- .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가
- .groupBy(rfqs.status)
- .having(gt(count(), 0));
-}
-
-export async function insertRfqItem(
- tx: PgTransaction<any, any, any>,
- data: NewRfqItem
-) {
- return tx.insert(rfqItems).values(data).returning();
-}
-
-export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => {
- // 1) RFQ 단건 조회
- const rfqsRes = await db
- .select()
- .from(rfqsView)
- .where(eq(rfqsView.id, id))
- .limit(1);
-
- if (rfqsRes.length === 0) return null;
- const rfqRow = rfqsRes[0];
-
- // 2) 해당 RFQ 아이템 목록 조회
- const itemsRes = await db
- .select()
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, id));
-
- // itemsRes: RfqItem[]
-
- // 3) RfqWithItems 형태로 반환
- const result: RfqViewWithItems = {
- ...rfqRow,
- lines: itemsRes,
- };
-
- return result;
-};
-
-/** 단건 업데이트 */
-export async function updateRfqVendor(
- tx: PgTransaction<any, any, any>,
- rfqVendorId: number,
- data: Partial<VendorResponse>
-) {
- return tx
- .update(vendorResponses)
- .set(data)
- .where(eq(vendorResponses.id, rfqVendorId))
- .returning({ status: vendorResponses.responseStatus });
-}
-
-/** 복수 업데이트 */
-export async function updateRfqVendors(
- tx: PgTransaction<any, any, any>,
- ids: number[],
- data: Partial<VendorResponse>
-) {
- return tx
- .update(vendorResponses)
- .set(data)
- .where(inArray(vendorResponses.id, ids))
- .returning({ status: vendorResponses.responseStatus });
-}
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts
deleted file mode 100644
index 651c8eda..00000000
--- a/lib/rfqs/service.ts
+++ /dev/null
@@ -1,3951 +0,0 @@
-// src/lib/tasks/service.ts
-"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-
-import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-
-import { filterColumns } from "@/lib/filter-columns";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { getErrorMessage } from "@/lib/handle-error";
-
-import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm";
-import path from "path";
-import { writeFile, mkdir } from 'fs/promises'
-import { join } from 'path'
-
-import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq";
-import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository";
-import logger from '@/lib/logger';
-import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors";
-import { sendEmail } from "../mail/sendEmail";
-import { biddingProjects, projects } from "@/db/schema/projects";
-import { items } from "@/db/schema/items";
-import * as z from "zod"
-import { users } from "@/db/schema/users";
-import { headers } from 'next/headers';
-
-// DRM 복호화 관련 유틸 import
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { deleteFile, saveDRMFile, saveFile } from "../file-stroage";
-
-interface InviteVendorsInput {
- rfqId: number
- vendorIds: number[]
- rfqType: RfqType
-}
-
-/* -----------------------------------------------------
- 1) 조회 관련
------------------------------------------------------ */
-
-/**
- * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고,
- * 총 개수에 따라 pageCount를 계산해서 리턴.
- * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
- */
-export async function getRfqs(input: GetRfqsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: rfqsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
-
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s)
- , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s)
- )
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
- }
-
- let rfqTypeWhere;
- if (input.rfqType) {
- rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType);
- }
-
- let whereConditions = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqTypeWhere) whereConditions.push(rfqTypeWhere);
-
- // 조건이 있을 때만 and() 사용
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
-
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id])
- )
- : [asc(rfqsView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectRfqs(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countRfqs(tx, finalWhere);
- return { data, total };
- });
-
-
- const pageCount = Math.ceil(total / input.perPage);
-
-
- return { data, pageCount };
- } catch (err) {
- console.error("getRfqs 에러:", err); // 자세한 에러 로깅
-
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: [`rfqs-${input.rfqType}`],
- }
- )();
-}
-
-/** Status별 개수 */
-export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) {
- return unstable_cache(
- async () => {
- try {
- const initial: Record<Rfq["status"], number> = {
- DRAFT: 0,
- PUBLISHED: 0,
- EVALUATION: 0,
- AWARDED: 0,
- };
-
- const result = await db.transaction(async (tx) => {
- // rfqType을 기준으로 필터링 추가
- const rows = await groupByStatus(tx, rfqType);
- return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => {
- acc[status] = count;
- return acc;
- }, initial);
- });
-
- return result;
- } catch (err) {
- return {} as Record<Rfq["status"], number>;
- }
- },
- [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가
- {
- revalidate: 3600,
- }
- )();
-}
-
-
-
-/* -----------------------------------------------------
- 2) 생성(Create)
------------------------------------------------------ */
-
-/**
- * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로
- * 전체 Rfq 개수를 고정
- */
-export async function createRfq(input: CreateRfqSchema) {
-
- console.log(input.createdBy, "input.createdBy")
-
- unstable_noStore(); // Next.js 서버 액션 캐싱 방지
- try {
- await db.transaction(async (tx) => {
- // 새 Rfq 생성
- const [newTask] = await insertRfq(tx, {
- rfqCode: input.rfqCode,
- projectId: input.projectId || null,
- bidProjectId: input.bidProjectId || null,
- description: input.description || null,
- dueDate: input.dueDate,
- status: input.status,
- rfqType: input.rfqType, // rfqType 추가
- createdBy: input.createdBy,
- });
- return newTask;
- });
-
- // 캐시 무효화
- revalidateTag(`rfqs-${input.rfqType}`);
- revalidateTag(`rfq-status-counts-${input.rfqType}`);
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 3) 업데이트
------------------------------------------------------ */
-
-/** 단건 업데이트 */
-export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
- unstable_noStore();
- try {
- const data = await db.transaction(async (tx) => {
- const [res] = await updateRfq(tx, input.id, {
- rfqCode: input.rfqCode,
- projectId: input.projectId || null,
- dueDate: input.dueDate,
- rfqType: input.rfqType,
- status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
- createdBy: input.createdBy,
- });
- return res;
- });
-
- revalidateTag("rfqs");
- if (data.status === input.status) {
- revalidateTag("rfqs-status-counts");
- }
-
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-export async function modifyRfqs(input: {
- ids: number[];
- status?: Rfq["status"];
- dueDate?: Date
-}) {
- unstable_noStore();
- try {
- const data = await db.transaction(async (tx) => {
- const [res] = await updateRfqs(tx, input.ids, {
- status: input.status,
- dueDate: input.dueDate,
- });
- return res;
- });
-
- revalidateTag("rfqs");
- if (data.status === input.status) {
- revalidateTag("rfq-status-counts");
- }
-
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-
-/* -----------------------------------------------------
- 4) 삭제
------------------------------------------------------ */
-
-/** 단건 삭제 */
-export async function removeRfq(input: { id: number }) {
- unstable_noStore();
- try {
- await db.transaction(async (tx) => {
- // 삭제
- await deleteRfqById(tx, input.id);
- // 바로 새 Rfq 생성
- });
-
- revalidateTag("rfqs");
- revalidateTag("rfq-status-counts");
-
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/** 복수 삭제 */
-export async function removeRfqs(input: { ids: number[] }) {
- unstable_noStore();
- try {
- await db.transaction(async (tx) => {
- // 삭제
- await deleteRfqsByIds(tx, input.ids);
- });
-
- revalidateTag("rfqs");
- revalidateTag("rfq-status-counts");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-// 삭제를 위한 입력 스키마
-const deleteRfqItemSchema = z.object({
- id: z.number().int(),
- rfqId: z.number().int(),
- rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE),
-});
-
-type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>;
-
-/**
- * RFQ 아이템 삭제 함수
- */
-export async function deleteRfqItem(input: DeleteRfqItemSchema) {
- unstable_noStore(); // Next.js 서버 액션 캐싱 방지
-
- try {
- // 삭제 작업 수행
- await db
- .delete(rfqItems)
- .where(
- and(
- eq(rfqItems.id, input.id),
- eq(rfqItems.rfqId, input.rfqId)
- )
- );
-
- console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`);
-
- // 캐시 무효화
- revalidateTag("rfq-items");
- revalidateTag(`rfqs-${input.rfqType}`);
- revalidateTag(`rfq-${input.rfqId}`);
-
- return { data: null, error: null };
- } catch (err) {
- console.error("Error in deleteRfqItem:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-// createRfqItem 함수 수정 (id 파라미터 추가)
-export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) {
- unstable_noStore();
-
- try {
- // DB 트랜잭션
- await db.transaction(async (tx) => {
- // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행
- if (input.id) {
- // 기존 아이템 업데이트
- await tx
- .update(rfqItems)
- .set({
- description: input.description ?? null,
- quantity: input.quantity ?? 1,
- uom: input.uom ?? "",
- updatedAt: new Date(),
- })
- .where(eq(rfqItems.id, input.id));
-
- console.log(`Updated RFQ item with id: ${input.id}`);
- } else {
- // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성
- const existingItems = await tx
- .select()
- .from(rfqItems)
- .where(
- and(
- eq(rfqItems.rfqId, input.rfqId),
- eq(rfqItems.itemCode, input.itemCode)
- )
- );
-
- if (existingItems.length > 0) {
- // 이미 존재하는 경우 업데이트
- const existingItem = existingItems[0];
- await tx
- .update(rfqItems)
- .set({
- description: input.description ?? null,
- quantity: input.quantity ?? 1,
- uom: input.uom ?? "",
- updatedAt: new Date(),
- })
- .where(eq(rfqItems.id, existingItem.id));
-
- console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`);
- } else {
- // 존재하지 않는 경우 새로 생성
- const [newItem] = await insertRfqItem(tx, {
- rfqId: input.rfqId,
- itemCode: input.itemCode,
- description: input.description ?? null,
- quantity: input.quantity ?? 1,
- uom: input.uom ?? "",
- });
-
- console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`);
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag("rfq-items");
- revalidateTag(`rfqs-${input.rfqType}`);
- revalidateTag(`rfq-${input.rfqId}`);
-
- return { data: null, error: null };
- } catch (err) {
- console.error("Error in createRfqItem:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-/**
- * 서버 액션: 파일 첨부/삭제 처리
- * @param rfqId RFQ ID
- * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열
- * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서
- * @param vendorId (optional) 업로더가 vendor인지 구분
- */
-export async function processRfqAttachments(args: {
- rfqId: number;
- removedExistingIds?: number[];
- newFiles?: File[];
- vendorId?: number | null;
- rfqType?: RfqType | null;
-}) {
- const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args;
-
- try {
- // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거
- if (removedExistingIds.length > 0) {
- // 1-1) DB에서 filePath 조회
- const rows = await db
- .select({
- id: rfqAttachments.id,
- filePath: rfqAttachments.filePath
- })
- .from(rfqAttachments)
- .where(inArray(rfqAttachments.id, removedExistingIds));
-
- // 1-2) DB 삭제
- await db
- .delete(rfqAttachments)
- .where(inArray(rfqAttachments.id, removedExistingIds));
-
- // 1-3) 파일 삭제
- for (const row of rows) {
- await deleteFile(row.filePath!);
- }
- }
-
- // 2) 새 파일 업로드
- if (newFiles.length > 0) {
- for (const file of newFiles) {
-
- const saveResult = await saveDRMFile(file, decryptWithServerAction,'rfq' )
-
- // 2-4) DB Insert
- await db.insert(rfqAttachments).values({
- rfqId,
- vendorId,
- fileName: file.name,
- filePath: saveResult.publicPath!,
- // (Windows 경로 대비)
- });
- }
- }
-
- const [countRow] = await db
- .select({ cnt: sql<number>`count(*)`.as("cnt") })
- .from(rfqAttachments)
- .where(eq(rfqAttachments.rfqId, rfqId));
-
- const newCount = countRow?.cnt ?? 0;
-
- // 3) revalidateTag 등 캐시 무효화
- revalidateTag("rfq-attachments");
- revalidateTag(`rfqs-${args.rfqType}`)
-
- return { ok: true, updatedItemCount: newCount };
- } catch (error) {
- console.error("processRfqAttachments error:", error);
- return { ok: false, error: String(error) };
- }
-}
-
-
-
-export async function fetchRfqAttachments(rfqId: number) {
- // DB select
- const rows = await db
- .select()
- .from(rfqAttachments)
- .where(eq(rfqAttachments.rfqId, rfqId))
-
- // rows: { id, fileName, filePath, createdAt, vendorId, ... }
- // 필요 없는 필드는 omit하거나 transform 가능
- return rows.map((row) => ({
- id: row.id,
- fileName: row.fileName,
- filePath: row.filePath,
- createdAt: row.createdAt, // or string
- vendorId: row.vendorId,
- size: undefined, // size를 DB에 저장하지 않았다면
- }))
-}
-
-export async function fetchRfqItems(rfqId: number) {
- // DB select
- const rows = await db
- .select()
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
-
- // rows: { id, fileName, filePath, createdAt, vendorId, ... }
- // 필요 없는 필드는 omit하거나 transform 가능
- return rows.map((row) => ({
- // id: row.id,
- itemCode: row.itemCode,
- description: row.description,
- quantity: row.quantity,
- uom: row.uom,
- }))
-}
-
-export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => {
- try {
- logger.info({ id }, 'Fetching user by ID');
- const rfq = await getRfqById(id);
- if (!rfq) {
- logger.warn({ id }, 'User not found');
- } else {
- logger.debug({ rfq }, 'User fetched successfully');
- }
- return rfq;
- } catch (error) {
- logger.error({ error }, 'Error fetching user by ID');
- throw new Error('Failed to fetch user');
- }
-};
-
-export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) {
- return unstable_cache(
- async () => {
- // ─────────────────────────────────────────────────────
- // 1) rfq_items에서 distinct itemCode
- // ─────────────────────────────────────────────────────
- const itemRows = await db
- .select({ code: rfqItems.itemCode })
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
- .groupBy(rfqItems.itemCode)
-
- const itemCodes = itemRows.map((r) => r.code)
- const itemCount = itemCodes.length
- if (itemCount === 0) {
- return { data: [], pageCount: 0 }
- }
-
- // ─────────────────────────────────────────────────────
- // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor
- // ─────────────────────────────────────────────────────
- const inList = itemCodes.map((c) => `'${c}'`).join(",")
- const sqlVendorIds = await db.execute(
- sql`
- SELECT vpi.vendor_id AS "vendorId"
- FROM ${vendorPossibleItems} vpi
- WHERE vpi.item_code IN (${sql.raw(inList)})
- GROUP BY vpi.vendor_id
- HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount}
- `
- )
- const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId)
- if (vendorIdList.length === 0) {
- return { data: [], pageCount: 0 }
- }
-
- // ─────────────────────────────────────────────────────
- // 3) 필터/검색/정렬
- // ─────────────────────────────────────────────────────
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // (가) 커스텀 필터
- // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다.
- const advancedWhere = filterColumns({
- // 테이블이 아니라 "뷰"를 넘길 수도 있고,
- // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다.
- table: vendorRfqView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // (나) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorRfqView.vendorName} ILIKE ${s}`,
- sql`${vendorRfqView.vendorCode} ILIKE ${s}`,
- sql`${vendorRfqView.email} ILIKE ${s}`
- )
- }
-
- // (다) 최종 where
- // vendorId가 vendorIdList 내에 있어야 하고,
- // 특정 rfqId(뷰에 담긴 값)도 일치해야 함.
- const finalWhere = and(
- inArray(vendorRfqView.vendorId, vendorIdList),
- // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만
- // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다
- // eq(vendorRfqView.rfqId, rfqId),
- advancedWhere,
- globalWhere
- )
-
- // (라) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- // "column id" -> vendorRfqView.* 중 하나
- const col = (vendorRfqView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
- })
- : [asc(vendorRfqView.vendorId)]
-
- // ─────────────────────────────────────────────────────
- // 4) View에서 데이터 SELECT
- // ─────────────────────────────────────────────────────
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- id: vendorRfqView.vendorId,
- vendorID: vendorRfqView.vendorId,
- vendorName: vendorRfqView.vendorName,
- vendorCode: vendorRfqView.vendorCode,
- address: vendorRfqView.address,
- country: vendorRfqView.country,
- email: vendorRfqView.email,
- website: vendorRfqView.website,
- vendorStatus: vendorRfqView.vendorStatus,
- // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정
- rfqVendorStatus: vendorRfqView.rfqVendorStatus,
- rfqVendorUpdated: vendorRfqView.rfqVendorUpdated,
- })
- .from(vendorRfqView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
-
- // 중복 제거된 데이터 생성
- const distinctData = Array.from(
- new Map(data.map(row => [row.id, row])).values()
- )
-
- // 중복 제거된 총 개수 계산
- const [{ count }] = await tx
- .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") })
- .from(vendorRfqView)
- .where(finalWhere)
-
- return [distinctData, Number(count)]
- })
-
-
- // ─────────────────────────────────────────────────────
- // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회
- // ─────────────────────────────────────────────────────
- const distinctVendorIds = [...new Set(rows.map((r) => r.id))]
-
- // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회
- const vendorStatuses = await db
- .select({
- vendorId: vendorResponses.vendorId,
- status: vendorResponses.responseStatus,
- updatedAt: vendorResponses.updatedAt
- })
- .from(vendorResponses)
- .where(
- and(
- inArray(vendorResponses.vendorId, distinctVendorIds),
- eq(vendorResponses.rfqId, rfqId)
- )
- )
-
- // vendorId별 상태정보 맵 생성
- const statusMap = new Map<number, { status: string, updatedAt: Date }>()
- for (const vs of vendorStatuses) {
- statusMap.set(vs.vendorId, {
- status: vs.status,
- updatedAt: vs.updatedAt
- })
- }
-
- // 정확한 상태 정보로 업데이트된 rows 생성
- const updatedRows = rows.map(row => ({
- ...row,
- rfqVendorStatus: statusMap.get(row.id)?.status || null,
- rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null
- }))
-
- // ─────────────────────────────────────────────────────
- // 5) 코멘트 조회: 기존과 동일
- // ─────────────────────────────────────────────────────
- console.log("distinctVendorIds", distinctVendorIds)
- const commAll = await db
- .select()
- .from(rfqComments)
- .where(
- and(
- inArray(rfqComments.vendorId, distinctVendorIds),
- eq(rfqComments.rfqId, rfqId),
- isNull(rfqComments.evaluationId),
- isNull(rfqComments.cbeId)
- )
- )
-
- const commByVendorId = new Map<number, any[]>()
- // 먼저 모든 사용자 ID를 수집
- const userIds = new Set(commAll.map(c => c.commentedBy));
- const userIdsArray = Array.from(userIds);
-
- // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴
- const usersData = await db
- .select({
- id: users.id,
- email: users.email,
- })
- .from(users)
- .where(inArray(users.id, userIdsArray));
-
- // 사용자 ID를 키로 하는 맵 생성
- const userMap = new Map();
- for (const user of usersData) {
- userMap.set(user.id, user);
- }
-
- // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
-
- // 사용자 정보 가져오기
- const user = userMap.get(c.commentedBy);
- const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정
-
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- commentedByEmail: userEmail, // 이메일 추가
- })
- }
- // ─────────────────────────────────────────────────────
- // 6) rows에 comments 병합
- // ─────────────────────────────────────────────────────
- const final = updatedRows.map((row) => ({
- ...row,
- comments: commByVendorId.get(row.id) ?? [],
- }))
-
- // ─────────────────────────────────────────────────────
- // 7) 반환
- // ─────────────────────────────────────────────────────
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify({ input, rfqId })],
- { revalidate: 3600, tags: ["rfq-vendors"] }
- )()
-}
-
-export async function inviteVendors(input: InviteVendorsInput) {
- unstable_noStore() // 서버 액션 캐싱 방지
- try {
- const { rfqId, vendorIds } = input
- if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) {
- throw new Error("Invalid input")
- }
-
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
-
- // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션
- const rfqData = await db.transaction(async (tx) => {
- // 2-A) RFQ 기본 정보 조회
- const [rfqRow] = await tx
- .select({
- rfqCode: rfqsView.rfqCode,
- description: rfqsView.description,
- projectCode: rfqsView.projectCode,
- projectName: rfqsView.projectName,
- dueDate: rfqsView.dueDate,
- createdBy: rfqsView.createdBy,
- })
- .from(rfqsView)
- .where(eq(rfqsView.id, rfqId))
-
- if (!rfqRow) {
- throw new Error(`RFQ #${rfqId} not found`)
- }
-
- // 2-B) 아이템 목록 조회
- const items = await tx
- .select({
- itemCode: rfqItems.itemCode,
- description: rfqItems.description,
- quantity: rfqItems.quantity,
- uom: rfqItems.uom,
- })
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
-
- // 2-C) 첨부파일 목록 조회
- const attachRows = await tx
- .select({
- id: rfqAttachments.id,
- fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath,
- })
- .from(rfqAttachments)
- .where(
- and(
- eq(rfqAttachments.rfqId, rfqId),
- isNull(rfqAttachments.vendorId),
- isNull(rfqAttachments.evaluationId)
- )
- )
-
- const vendorRows = await tx
- .select({ id: vendors.id, email: vendors.email })
- .from(vendors)
- .where(inArray(vendors.id, vendorIds))
-
- // NodeMailer attachments 형식 맞추기
- const attachments = []
- for (const att of attachRows) {
- const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, ""))
- attachments.push({
- path: absolutePath,
- filename: att.fileName,
- })
- }
-
- return { rfqRow, items, vendorRows, attachments }
- })
-
- const { rfqRow, items, vendorRows, attachments } = rfqData
- const loginUrl = `http://${host}/en/partners/rfq`
-
- // 이메일 전송 오류를 기록할 배열
- const emailErrors = []
-
- // 각 벤더에 대해 처리
- for (const v of vendorRows) {
- if (!v.email) {
- continue // 이메일 없는 협력업체 무시
- }
-
- try {
- // DB 업데이트: 각 협력업체 상태 별도 트랜잭션
- await db.transaction(async (tx) => {
- // rfq_vendors upsert
- const existing = await tx
- .select()
- .from(vendorResponses)
- .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id)))
-
- if (existing.length > 0) {
- await tx
- .update(vendorResponses)
- .set({
- responseStatus: "INVITED",
- updatedAt: new Date(),
- })
- .where(eq(vendorResponses.id, existing[0].id))
- } else {
- await tx.insert(vendorResponses).values({
- rfqId,
- vendorId: v.id,
- responseStatus: "INVITED",
- })
- }
- })
-
- // 이메일 발송 (트랜잭션 외부)
- await sendEmail({
- to: v.email,
- subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`,
- template: "rfq-invite",
- context: {
- language: "en",
- rfqId,
- vendorId: v.id,
- rfqCode: rfqRow.rfqCode,
- projectCode: rfqRow.projectCode,
- projectName: rfqRow.projectName,
- dueDate: rfqRow.dueDate,
- description: rfqRow.description,
- items: items.map((it) => ({
- itemCode: it.itemCode,
- description: it.description,
- quantity: it.quantity,
- uom: it.uom,
- })),
- loginUrl
- },
- attachments,
- })
- } catch (err) {
- // 개별 협력업체 처리 실패 로깅
- console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`)
- emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) })
- // 계속 진행 (다른 협력업체 처리)
- }
- }
-
- // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션)
- try {
- await db.transaction(async (tx) => {
- await tx
- .update(rfqs)
- .set({
- status: "PUBLISHED",
- updatedAt: new Date(),
- })
- .where(eq(rfqs.id, rfqId))
-
- console.log(`Updated RFQ #${rfqId} status to PUBLISHED`)
- })
-
- // 캐시 무효화
- revalidateTag("rfq-vendors")
- revalidateTag("cbe-vendors")
- revalidateTag("rfqs")
- revalidateTag(`rfqs-${input.rfqType}`)
- revalidateTag(`rfq-${rfqId}`)
-
- // 이메일 오류가 있었는지 확인
- if (emailErrors.length > 0) {
- return {
- error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`,
- emailErrors
- }
- }
-
- return { error: null }
- } catch (err) {
- return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` }
- }
- } catch (err) {
- return { error: getErrorMessage(err) }
- }
-}
-
-
-/**
- * TBE용 평가 데이터 목록 조회
- */
-export async function getTBE(input: GetTBESchema, rfqId: number) {
- return unstable_cache(
- async () => {
- // 1) 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // 2) 고급 필터
- const advancedWhere = filterColumns({
- table: vendorTbeView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // 3) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorTbeView.vendorName} ILIKE ${s}`,
- sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorTbeView.email} ILIKE ${s}`
- )
- }
-
- // 4) REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorTbeView.rfqVendorStatus)
- )
-
- // 5) finalWhere
- const finalWhere = and(
- eq(vendorTbeView.rfqId, rfqId),
- // notRejected,
- advancedWhere,
- globalWhere
- )
-
- // 6) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- const col = (vendorTbeView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
- })
- : [asc(vendorTbeView.vendorId)]
-
- // 7) 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 원하는 컬럼들
- id: vendorTbeView.vendorId,
- tbeId: vendorTbeView.tbeId,
- vendorId: vendorTbeView.vendorId,
- vendorName: vendorTbeView.vendorName,
- vendorCode: vendorTbeView.vendorCode,
- address: vendorTbeView.address,
- country: vendorTbeView.country,
- email: vendorTbeView.email,
- website: vendorTbeView.website,
- vendorStatus: vendorTbeView.vendorStatus,
-
- rfqId: vendorTbeView.rfqId,
- rfqCode: vendorTbeView.rfqCode,
- projectCode: vendorTbeView.projectCode,
- projectName: vendorTbeView.projectName,
- description: vendorTbeView.description,
- dueDate: vendorTbeView.dueDate,
-
- rfqVendorStatus: vendorTbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
-
- tbeResult: vendorTbeView.tbeResult,
- tbeNote: vendorTbeView.tbeNote,
- tbeUpdated: vendorTbeView.tbeUpdated,
-
- technicalResponseId:vendorTbeView.technicalResponseId,
- technicalResponseStatus:vendorTbeView.technicalResponseStatus,
- technicalSummary:vendorTbeView.technicalSummary,
- technicalNotes:vendorTbeView.technicalNotes,
- technicalUpdated:vendorTbeView.technicalUpdated,
- })
- .from(vendorTbeView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorTbeView)
- .where(finalWhere)
-
- return [data, Number(count)]
- })
-
- if (!rows.length) {
- return { data: [], pageCount: 0 }
- }
-
- // 8) Comments 조회
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
-
- const commAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- evaluationId: rfqComments.evaluationId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- evalType: rfqEvaluations.evalType,
- })
- .from(rfqComments)
- .innerJoin(
- rfqEvaluations,
- and(
- eq(rfqEvaluations.id, rfqComments.evaluationId),
- eq(rfqEvaluations.evalType, "TBE")
- )
- )
- .where(
- and(
- isNotNull(rfqComments.evaluationId),
- eq(rfqComments.rfqId, rfqId),
- inArray(rfqComments.vendorId, distinctVendorIds)
- )
- )
-
- // 8-A) vendorId -> comments grouping
- const commByVendorId = new Map<number, any[]>()
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- })
- }
-
- // 9) TBE 파일 조회 - vendorResponseAttachments로 대체
- // Step 1: Get vendorResponses for the rfqId and vendorIds
- const responsesAll = await db
- .select({
- id: vendorResponses.id,
- vendorId: vendorResponses.vendorId
- })
- .from(vendorResponses)
- .where(
- and(
- eq(vendorResponses.rfqId, rfqId),
- inArray(vendorResponses.vendorId, distinctVendorIds)
- )
- );
-
- // Group responses by vendorId for later lookup
- const responsesByVendorId = new Map<number, number[]>();
- for (const resp of responsesAll) {
- if (!responsesByVendorId.has(resp.vendorId)) {
- responsesByVendorId.set(resp.vendorId, []);
- }
- responsesByVendorId.get(resp.vendorId)!.push(resp.id);
- }
-
- // Step 2: Get all responseIds
- const allResponseIds = responsesAll.map(r => r.id);
-
- // Step 3: Get technicalResponses for these responseIds
- const technicalResponsesAll = await db
- .select({
- id: vendorTechnicalResponses.id,
- responseId: vendorTechnicalResponses.responseId
- })
- .from(vendorTechnicalResponses)
- .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
-
- // Create mapping from responseId to technicalResponseIds
- const technicalResponseIdsByResponseId = new Map<number, number[]>();
- for (const tr of technicalResponsesAll) {
- if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
- technicalResponseIdsByResponseId.set(tr.responseId, []);
- }
- technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
- }
-
- // Step 4: Get all technicalResponseIds
- const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
-
- // Step 5: Get attachments for these technicalResponseIds
- const filesAll = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- technicalResponseId: vendorResponseAttachments.technicalResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
- isNotNull(vendorResponseAttachments.technicalResponseId)
- )
- );
-
- // Step 6: Create mapping from technicalResponseId to attachments
- const filesByTechnicalResponseId = new Map<number, any[]>();
- for (const file of filesAll) {
- // Skip if technicalResponseId is null (should never happen due to our filter above)
- if (file.technicalResponseId === null) continue;
-
- if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
- filesByTechnicalResponseId.set(file.technicalResponseId, []);
- }
- filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy
- });
- }
-
- // Step 7: Create the final filesByVendorId map
- const filesByVendorId = new Map<number, any[]>();
- for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
- filesByVendorId.set(vendorId, []);
-
- for (const responseId of responseIds) {
- const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
-
- for (const technicalResponseId of technicalResponseIds) {
- const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
- filesByVendorId.get(vendorId)!.push(...files);
- }
- }
- }
-
- // 10) 최종 합치기
- const final = rows.map((row) => ({
- ...row,
- dueDate: row.dueDate ? new Date(row.dueDate) : null,
- comments: commByVendorId.get(row.vendorId) ?? [],
- files: filesByVendorId.get(row.vendorId) ?? [],
- }))
-
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify({ input, rfqId })],
- {
- revalidate: 3600,
- tags: ["tbe-vendors"],
- }
- )()
-}
-
-export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
-
- if (isNaN(vendorId) || vendorId === null || vendorId === undefined) {
- throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다");
- }
-
- return unstable_cache(
- async () => {
- // 1) 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // 2) 고급 필터
- const advancedWhere = filterColumns({
- table: vendorTbeView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // 3) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorTbeView.vendorName} ILIKE ${s}`,
- sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorTbeView.email} ILIKE ${s}`
- )
- }
-
- // 4) REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorTbeView.rfqVendorStatus)
- )
-
- // 5) finalWhere
- const finalWhere = and(
- isNotNull(vendorTbeView.tbeId),
- eq(vendorTbeView.vendorId, vendorId),
- // notRejected,
- advancedWhere,
- globalWhere
- )
-
- // 6) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- const col = (vendorTbeView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
- })
- : [asc(vendorTbeView.vendorId)]
-
- // 7) 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 원하는 컬럼들
- id: vendorTbeView.vendorId,
- tbeId: vendorTbeView.tbeId,
- vendorId: vendorTbeView.vendorId,
- vendorName: vendorTbeView.vendorName,
- vendorCode: vendorTbeView.vendorCode,
- address: vendorTbeView.address,
- country: vendorTbeView.country,
- email: vendorTbeView.email,
- website: vendorTbeView.website,
- vendorStatus: vendorTbeView.vendorStatus,
-
- rfqId: vendorTbeView.rfqId,
- rfqCode: vendorTbeView.rfqCode,
- rfqType:vendorTbeView.rfqType,
- rfqStatus:vendorTbeView.rfqStatus,
- rfqDescription: vendorTbeView.description,
- rfqDueDate: vendorTbeView.dueDate,
-
-
- projectCode: vendorTbeView.projectCode,
- projectName: vendorTbeView.projectName,
- description: vendorTbeView.description,
- dueDate: vendorTbeView.dueDate,
-
- vendorResponseId: vendorTbeView.vendorResponseId,
- rfqVendorStatus: vendorTbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
-
- tbeResult: vendorTbeView.tbeResult,
- tbeNote: vendorTbeView.tbeNote,
- tbeUpdated: vendorTbeView.tbeUpdated,
- })
- .from(vendorTbeView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorTbeView)
- .where(finalWhere)
-
- return [data, Number(count)]
- })
-
- if (!rows.length) {
- return { data: [], pageCount: 0 }
- }
-
- // 8) Comments 조회
- // - evaluationId != null && evalType = "TBE"
- // - => leftJoin(rfqEvaluations) or innerJoin
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
- const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))]
-
- // (A) 조인 방식
- const commAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- evaluationId: rfqComments.evaluationId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- evalType: rfqEvaluations.evalType, // (optional)
- })
- .from(rfqComments)
- // evalType = 'TBE'
- .innerJoin(
- rfqEvaluations,
- and(
- eq(rfqEvaluations.id, rfqComments.evaluationId),
- eq(rfqEvaluations.evalType, "TBE") // ★ TBE만
- )
- )
- .where(
- and(
- isNotNull(rfqComments.evaluationId),
- inArray(rfqComments.vendorId, distinctVendorIds)
- )
- )
-
- // 8-A) vendorId -> comments grouping
- const commByVendorId = new Map<number, any[]>()
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- })
- }
-
- // 9) TBE 템플릿 파일 수 조회
- const templateFiles = await db
- .select({
- tbeId: rfqAttachments.evaluationId,
- fileCount: sql<number>`count(*)`.as("file_count"),
- })
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.evaluationId, distinctTbeIds),
- isNull(rfqAttachments.vendorId),
- isNull(rfqAttachments.commentId)
- )
- )
- .groupBy(rfqAttachments.evaluationId)
-
- // tbeId -> fileCount 매핑 - null 체크 추가
- const templateFileCountMap = new Map<number, number>()
- for (const tf of templateFiles) {
- if (tf.tbeId !== null) {
- templateFileCountMap.set(tf.tbeId, Number(tf.fileCount))
- }
- }
-
- // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해)
- const tbeResponseFiles = await db
- .select({
- tbeId: rfqAttachments.evaluationId,
- vendorId: rfqAttachments.vendorId,
- responseFileCount: sql<number>`count(*)`.as("response_file_count"),
- })
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.evaluationId, distinctTbeIds),
- inArray(rfqAttachments.vendorId, distinctVendorIds),
- isNull(rfqAttachments.commentId)
- )
- )
- .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId)
-
- // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가
- const tbeResponseMap = new Map<string, number>()
- for (const rf of tbeResponseFiles) {
- if (rf.tbeId !== null && rf.vendorId !== null) {
- const key = `${rf.tbeId}_${rf.vendorId}`
- tbeResponseMap.set(key, Number(rf.responseFileCount))
- }
- }
-
- // 11) 최종 합치기
- const final = rows.map((row) => {
- const tbeId = row.tbeId
- const vendorId = row.vendorId
-
- // 템플릿 파일 수
- const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0
-
- // 응답 파일 여부
- const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : ""
- const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0
-
- return {
- ...row,
- dueDate: row.dueDate ? new Date(row.dueDate) : null,
- comments: commByVendorId.get(row.vendorId) ?? [],
- templateFileCount, // 추가: 템플릿 파일 수
- hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부
- }
- })
-
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가
- {
- revalidate: 3600,
- tags: [`tbe-vendors-${vendorId}`],
- }
- )()
-}
-
-export async function inviteTbeVendorsAction(formData: FormData) {
- // 캐싱 방지
- unstable_noStore()
-
- try {
- // 1) FormData에서 기본 필드 추출
- const rfqId = Number(formData.get("rfqId"))
- const vendorIdsRaw = formData.getAll("vendorIds[]")
- const vendorIds = vendorIdsRaw.map((id) => Number(id))
-
- // 2) FormData에서 파일들 추출 (multiple)
- const tbeFiles = formData.getAll("tbeFiles") as File[]
- if (!rfqId || !vendorIds.length || !tbeFiles.length) {
- throw new Error("Invalid input or no files attached.")
- }
-
- // DB 트랜잭션
- await db.transaction(async (tx) => {
- // (A) RFQ 기본 정보 조회
- const [rfqRow] = await tx
- .select({
- rfqCode: vendorResponsesView.rfqCode,
- description: vendorResponsesView.rfqDescription,
- projectCode: vendorResponsesView.projectCode,
- projectName: vendorResponsesView.projectName,
- dueDate: vendorResponsesView.rfqDueDate,
- createdBy: vendorResponsesView.rfqCreatedBy,
- })
- .from(vendorResponsesView)
- .where(eq(vendorResponsesView.rfqId, rfqId))
-
- if (!rfqRow) {
- throw new Error(`RFQ #${rfqId} not found`)
- }
-
- // (B) RFQ 아이템 목록
- const items = await tx
- .select({
- itemCode: rfqItems.itemCode,
- description: rfqItems.description,
- quantity: rfqItems.quantity,
- uom: rfqItems.uom,
- })
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
-
- // (C) 대상 벤더들 (이메일 정보 확장)
- const vendorRows = await tx
- .select({
- id: vendors.id,
- name: vendors.vendorName,
- email: vendors.email,
- representativeEmail: vendors.representativeEmail // 대표자 이메일 추가
- })
- .from(vendors)
- .where(sql`${vendors.id} in (${vendorIds})`)
-
- // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리
- // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨.
- // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시.
- const savedFiles = []
- for (const file of tbeFiles) {
-
- const saveResult = await saveFile({file, directory:'rfb'});
- // 저장 경로 & 파일명 기록
- savedFiles.push({
- fileName: file.name, // 원본 파일명으로 첨부
- filePath: saveResult.publicPath, // public 이하 경로
- absolutePath: saveResult.publicPath,
- })
- }
-
- // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송
- for (const vendor of vendorRows) {
- // 1) 협력업체 연락처 조회 - 추가 이메일 수집
- const contacts = await tx
- .select({
- contactName: vendorContacts.contactName,
- contactEmail: vendorContacts.contactEmail,
- isPrimary: vendorContacts.isPrimary,
- })
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendor.id))
-
- // 2) 모든 이메일 주소 수집 및 중복 제거
- const allEmails = new Set<string>()
-
- // 협력업체 이메일 추가 (있는 경우에만)
- if (vendor.email) {
- allEmails.add(vendor.email.trim().toLowerCase())
- }
-
- // 협력업체 대표자 이메일 추가 (있는 경우에만)
- if (vendor.representativeEmail) {
- allEmails.add(vendor.representativeEmail.trim().toLowerCase())
- }
-
- // 연락처 이메일 추가
- contacts.forEach(contact => {
- if (contact.contactEmail) {
- allEmails.add(contact.contactEmail.trim().toLowerCase())
- }
- })
-
- // 중복이 제거된 이메일 주소 배열로 변환
- const uniqueEmails = Array.from(allEmails)
-
- if (uniqueEmails.length === 0) {
- console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`)
- continue
- }
-
- // 3) TBE 평가 레코드 생성
- const [evalRow] = await tx
- .insert(rfqEvaluations)
- .values({
- rfqId,
- vendorId: vendor.id,
- evalType: "TBE",
- })
- .returning({ id: rfqEvaluations.id })
-
- // 4) rfqAttachments에 저장한 파일들을 기록
- for (const sf of savedFiles) {
- await tx.insert(rfqAttachments).values({
- rfqId,
- vendorId: vendor.id,
- evaluationId: evalRow.id,
- fileName: sf.fileName,
- filePath: sf.filePath,
- })
- }
-
- // 5) 각 고유 이메일 주소로 초대 메일 발송
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
- const loginUrl = `${baseUrl}/ko/partners/rfq`
-
- console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`)
-
- for (const email of uniqueEmails) {
- try {
- // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체)
- const contact = contacts.find(c =>
- c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase()
- )
- const contactName = contact?.contactName || `${vendor.name} 담당자`
-
- await sendEmail({
- to: email,
- subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`,
- template: "rfq-invite",
- context: {
- language: "en",
- rfqId,
- vendorId: vendor.id,
- contactName, // 연락처 이름 추가
- rfqCode: rfqRow.rfqCode,
- projectCode: rfqRow.projectCode,
- projectName: rfqRow.projectName,
- dueDate: rfqRow.dueDate,
- description: rfqRow.description,
- items: items.map((it) => ({
- itemCode: it.itemCode,
- description: it.description,
- quantity: it.quantity,
- uom: it.uom,
- })),
- loginUrl,
- },
- attachments: savedFiles.map((sf) => ({
- path: sf.absolutePath,
- filename: sf.fileName,
- })),
- })
- console.log(`이메일 전송 성공: ${email} (${contactName})`)
- } catch (emailErr) {
- console.error(`이메일 전송 실패 (${email}):`, emailErr)
- }
- }
- }
-
- // 6) 캐시 무효화
- revalidateTag("tbe-vendors")
- })
-
- // 성공
- return { error: null }
- } catch (err) {
- console.error("[inviteTbeVendorsAction] Error:", err)
- return { error: getErrorMessage(err) }
- }
-}
-////partners
-
-
-export async function modifyRfqVendor(input: UpdateRfqVendorSchema) {
- unstable_noStore();
- try {
- const data = await db.transaction(async (tx) => {
- const [res] = await updateRfqVendor(tx, input.id, {
- responseStatus: input.status,
- });
- return res;
- });
-
- revalidateTag("rfqs-vendor");
- revalidateTag("rfq-vendors");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-export async function createRfqCommentWithAttachments(params: {
- rfqId: number
- vendorId?: number | null
- commentText: string
- commentedBy: number
- evaluationId?: number | null
- cbeId?: number | null
- files?: File[]
-}) {
- const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params
- console.log("cbeId", cbeId)
- console.log("evaluationId", evaluationId)
- // 1) 새로운 코멘트 생성
- const [insertedComment] = await db
- .insert(rfqComments)
- .values({
- rfqId,
- vendorId: vendorId || null,
- commentText,
- commentedBy,
- evaluationId: evaluationId || null,
- cbeId: cbeId || null,
- })
- .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록
-
- if (!insertedComment) {
- throw new Error("Failed to create comment")
- }
-
- // 2) 첨부파일 처리
- if (files && files.length > 0) {
-
- for (const file of files) {
-
- const saveResult = await saveFile({file, directory:'rfq'})
-
- // DB에 첨부파일 row 생성
- await db.insert(rfqAttachments).values({
- rfqId,
- vendorId: vendorId || null,
- evaluationId: evaluationId || null,
- cbeId: cbeId || null,
- commentId: insertedComment.id, // 새 코멘트와 연결
- fileName: file.name,
- filePath:saveResult.publicPath!,
- })
- }
- }
-
- revalidateTag("rfq-vendors");
-
- return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt }
-}
-
-export async function fetchRfqAttachmentsbyCommentId(commentId: number) {
- // DB select
- const rows = await db
- .select()
- .from(rfqAttachments)
- .where(eq(rfqAttachments.commentId, commentId))
-
- // rows: { id, fileName, filePath, createdAt, vendorId, ... }
- // 필요 없는 필드는 omit하거나 transform 가능
- return rows.map((row) => ({
- id: row.id,
- fileName: row.fileName,
- filePath: row.filePath,
- createdAt: row.createdAt, // or string
- vendorId: row.vendorId,
- evaluationId: row.evaluationId,
- size: undefined, // size를 DB에 저장하지 않았다면
- }))
-}
-
-export async function updateRfqComment(params: {
- commentId: number
- commentText: string
-}) {
- const { commentId, commentText } = params
-
- // 예: 간단한 길이 체크 등 유효성 검사
- if (!commentText || commentText.trim().length === 0) {
- throw new Error("Comment text must not be empty.")
- }
-
- // DB 업데이트
- const updatedRows = await db
- .update(rfqComments)
- .set({ commentText }) // 필요한 컬럼만 set
- .where(eq(rfqComments.id, commentId))
- .returning({ id: rfqComments.id })
-
- // 혹은 returning 전체(row)를 받아서 확인할 수도 있음
- if (updatedRows.length === 0) {
- // 해당 id가 없으면 예외
- throw new Error("Comment not found or already deleted.")
- }
- revalidateTag("rfq-vendors");
- return { ok: true }
-}
-
-export type Project = {
- id: number;
- projectCode: string;
- projectName: string;
- type: string;
-}
-
-export async function getProjects(): Promise<Project[]> {
- try {
- // 트랜잭션을 사용하여 프로젝트 데이터 조회
- const projectList = await db.transaction(async (tx) => {
- // 모든 프로젝트 조회
- const results = await tx
- .select({
- id: projects.id,
- projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정
- projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정
- type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정
- })
- .from(projects)
- .orderBy(projects.code);
-
- return results;
- });
-
- return projectList;
- } catch (error) {
- console.error("프로젝트 목록 가져오기 실패:", error);
- return []; // 오류 발생 시 빈 배열 반환
- }
-}
-
-
-export async function getBidProjects(): Promise<Project[]> {
- try {
- // 트랜잭션을 사용하여 프로젝트 데이터 조회
- const projectList = await db.transaction(async (tx) => {
- // 모든 프로젝트 조회
- const results = await tx
- .select({
- id: biddingProjects.id,
- projectCode: biddingProjects.pspid,
- projectName: biddingProjects.projNm,
- })
- .from(biddingProjects)
- .orderBy(biddingProjects.id);
-
- return results;
- });
-
- // Handle null projectName values
- const validProjectList = projectList.map(project => ({
- ...project,
- projectName: project.projectName || '' // Replace null with empty string
- }));
-
- return validProjectList;
- } catch (error) {
- console.error("프로젝트 목록 가져오기 실패:", error);
- return []; // 오류 발생 시 빈 배열 반환
- }
-}
-
-
-// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
-export interface BudgetaryRfq {
- id: number;
- rfqCode: string | null; // null 허용으로 변경
- description: string | null;
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
-}
-
-type GetBudgetaryRfqsResponse =
- | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
- | { error: string; rfqs?: never; totalCount: number }
-/**
- * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션
- * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
- * 페이징 및 필터링 기능 포함
- */
-export interface GetBudgetaryRfqsParams {
- search?: string;
- projectId?: number;
- rfqId?: number; // 특정 ID로 단일 RFQ 검색
- rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링
- limit?: number;
- offset?: number;
-}
-
-export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
- const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params;
- const cacheKey = `rfqs-query-${JSON.stringify(params)}`;
-
- return unstable_cache(
- async () => {
- try {
- // 기본 검색 조건 구성
- let baseCondition;
-
- // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우)
- if (rfqTypes && rfqTypes.length > 0) {
- // 여러 타입으로 필터링 (OR 조건)
- baseCondition = inArray(rfqs.rfqType, rfqTypes);
- } else {
- // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지)
- baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY);
- }
-
- // 특정 ID로 검색하는 경우
- if (rfqId) {
- baseCondition = and(baseCondition, eq(rfqs.id, rfqId));
- }
-
- let where1;
- // 검색어 조건 추가 (있을 경우)
- if (search && search.trim()) {
- const searchTerm = `%${search.trim()}%`;
- const searchCondition = or(
- ilike(rfqs.rfqCode, searchTerm),
- ilike(rfqs.description, searchTerm),
- ilike(projects.code, searchTerm),
- ilike(projects.name, searchTerm)
- );
- where1 = searchCondition;
- }
-
- let where2;
- // 프로젝트 ID 조건 추가 (있을 경우)
- if (projectId) {
- where2 = eq(rfqs.projectId, projectId);
- }
-
- const finalWhere = and(baseCondition, where1, where2);
-
- // 총 개수 조회
- const [countResult] = await db
- .select({ count: count() })
- .from(rfqs)
- .leftJoin(projects, eq(rfqs.projectId, projects.id))
- .where(finalWhere);
-
- // 실제 데이터 조회
- const resultRfqs = await db
- .select({
- id: rfqs.id,
- rfqCode: rfqs.rfqCode,
- description: rfqs.description,
- rfqType: rfqs.rfqType, // RFQ 타입 필드 추가
- projectId: rfqs.projectId,
- projectCode: projects.code,
- projectName: projects.name,
- })
- .from(rfqs)
- .leftJoin(projects, eq(rfqs.projectId, projects.id))
- .where(finalWhere)
- .orderBy(desc(rfqs.createdAt))
- .limit(limit)
- .offset(offset);
-
- return {
- rfqs: resultRfqs,
- totalCount: Number(countResult?.count) || 0
- };
- } catch (error) {
- console.error("Error fetching RFQs:", error);
- return {
- error: "Failed to fetch RFQs",
- totalCount: 0
- };
- }
- },
- [cacheKey],
- {
- revalidate: 60, // 1분 캐시
- tags: ["rfqs-query"],
- }
- )();
-}
-export async function getAllVendors() {
- // Adjust the query as needed (add WHERE, ORDER, etc.)
- const allVendors = await db.select().from(vendors)
- return allVendors
-}
-
-
-export async function getVendorContactsByVendorId(vendorId: number) {
- try {
- const contacts = await db.query.vendorContacts.findMany({
- where: eq(vendorContacts.vendorId, vendorId),
- });
-
- return { success: true, data: contacts };
- } catch (error) {
- console.error("Error fetching vendor contacts:", error);
- return { success: false, error: "Failed to fetch vendor contacts" };
- }
-}
-/**
- * Server action to associate items from an RFQ with a vendor
- *
- * @param rfqId - The ID of the RFQ containing items to associate
- * @param vendorId - The ID of the vendor to associate items with
- * @returns Object indicating success or failure
- */
-export async function addItemToVendors(rfqId: number, vendorIds: number[]) {
- try {
- // Input validation
- if (!vendorIds.length) {
- return {
- success: false,
- error: "No vendors selected"
- };
- }
-
- // 1. Find all itemCodes associated with the given rfqId using select
- const rfqItemResults = await db
- .select({ itemCode: rfqItems.itemCode })
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId));
-
- // Extract itemCodes
- const itemCodes = rfqItemResults.map(item => item.itemCode);
-
- if (itemCodes.length === 0) {
- return {
- success: false,
- error: "No items found for this RFQ"
- };
- }
-
- // 2. Find existing vendor-item combinations to avoid duplicates
- const existingCombinations = await db
- .select({
- vendorId: vendorPossibleItems.vendorId,
- itemCode: vendorPossibleItems.itemCode
- })
- .from(vendorPossibleItems)
- .where(
- and(
- inArray(vendorPossibleItems.vendorId, vendorIds),
- inArray(vendorPossibleItems.itemCode, itemCodes)
- )
- );
-
- // Create a Set of existing combinations for easy lookups
- const existingSet = new Set();
- existingCombinations.forEach(combo => {
- existingSet.add(`${combo.vendorId}-${combo.itemCode}`);
- });
-
- // 3. Prepare records to insert (only non-existing combinations)
- const recordsToInsert = [];
-
- for (const vendorId of vendorIds) {
- for (const itemCode of itemCodes) {
- const key = `${vendorId}-${itemCode}`;
- if (!existingSet.has(key)) {
- recordsToInsert.push({
- vendorId,
- itemCode,
- // createdAt and updatedAt will be set by defaultNow()
- });
- }
- }
- }
-
- // 4. Bulk insert if there are records to insert
- let insertedCount = 0;
- if (recordsToInsert.length > 0) {
- const result = await db.insert(vendorPossibleItems).values(recordsToInsert);
- insertedCount = recordsToInsert.length;
- }
-
- // 5. Revalidate to refresh data
- revalidateTag("rfq-vendors");
-
- // 6. Return success with counts
- return {
- success: true,
- insertedCount,
- totalPossibleItems: vendorIds.length * itemCodes.length,
- vendorCount: vendorIds.length,
- itemCount: itemCodes.length
- };
- } catch (error) {
- console.error("Error adding items to vendors:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown error"
- };
- }
-}
-
-/**
- * 특정 평가에 대한 TBE 템플릿 파일 목록 조회
- * evaluationId가 일치하고 vendorId가 null인 파일 목록
- */
-export async function fetchTbeTemplateFiles(evaluationId: number) {
- try {
- const files = await db
- .select({
- id: rfqAttachments.id,
- fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath,
- createdAt: rfqAttachments.createdAt,
- })
- .from(rfqAttachments)
- .where(
- and(
- isNull(rfqAttachments.commentId),
- isNull(rfqAttachments.vendorId),
- eq(rfqAttachments.evaluationId, evaluationId),
- // eq(rfqAttachments.vendorId, vendorId),
-
- )
- )
-
- return { files, error: null }
- } catch (error) {
- console.error("Error fetching TBE template files:", error)
- return {
- files: [],
- error: "템플릿 파일을 가져오는 중 오류가 발생했습니다."
- }
- }
-}
-
-export async function getFileFromRfqAttachmentsbyid(fileId: number) {
- try {
- const file = await db
- .select({
- fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath,
- })
- .from(rfqAttachments)
- .where(eq(rfqAttachments.id, fileId))
- .limit(1)
-
- if (!file.length) {
- return { file: null, error: "파일을 찾을 수 없습니다." }
- }
-
- return { file: file[0], error: null }
- } catch (error) {
- console.error("Error getting TBE template file info:", error)
- return {
- file: null,
- error: "파일 정보를 가져오는 중 오류가 발생했습니다."
- }
- }
-}
-
-/**
- * TBE 응답 파일 업로드 처리
- */
-export async function uploadTbeResponseFile(formData: FormData) {
- try {
- const file = formData.get("file") as File
- const rfqId = parseInt(formData.get("rfqId") as string)
- const vendorId = parseInt(formData.get("vendorId") as string)
- const evaluationId = parseInt(formData.get("evaluationId") as string)
- const vendorResponseId = parseInt(formData.get("vendorResponseId") as string)
-
- if (!file || !rfqId || !vendorId || !evaluationId) {
- return {
- success: false,
- error: "필수 필드가 누락되었습니다."
- }
- }
-
- // 타임스탬프 기반 고유 파일명 생성
- const timestamp = Date.now()
- const originalName = file.name
- const fileExtension = originalName.split(".").pop()
- const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}`
-
- // 업로드 디렉토리 및 경로 정의
- const uploadDir = join(process.cwd(), "rfq", "tbe-responses")
-
- // 디렉토리가 없으면 생성
- try {
- await mkdir(uploadDir, { recursive: true })
- } catch (error) {
- // 이미 존재하면 무시
- }
-
- const filePath = join(uploadDir, fileName)
-
- // 파일을 버퍼로 변환
- const bytes = await file.arrayBuffer()
- const buffer = Buffer.from(bytes)
-
- // 파일을 서버에 저장
- await writeFile(filePath, buffer)
-
- // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성
- const technicalResponse = await db.insert(vendorTechnicalResponses)
- .values({
- responseId: vendorResponseId,
- summary: "TBE 응답 파일 업로드", // 필요에 따라 수정
- notes: `파일명: ${originalName}`,
- responseStatus:"SUBMITTED"
- })
- .returning({ id: vendorTechnicalResponses.id });
-
- // 생성된 기술 응답 ID 가져오기
- const technicalResponseId = technicalResponse[0].id;
-
- // 파일 정보를 데이터베이스에 저장
- const dbFilePath = `/rfq/tbe-responses/${fileName}`
-
- // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입
- await db.insert(vendorResponseAttachments)
- .values({
- // 오류 메시지를 기반으로 올바른 필드 이름 사용
- // 테이블 스키마에 정의된 필드만 포함해야 함
- responseId: vendorResponseId,
- technicalResponseId: technicalResponseId,
- // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거
- // vendorId: vendorId,
- // evaluationId: evaluationId,
- fileName: originalName,
- filePath: dbFilePath,
- uploadedAt: new Date(),
- });
-
- // 경로 재검증 (캐시된 데이터 새로고침)
- revalidatePath(`/rfq/${rfqId}/tbe`)
- revalidateTag(`tbe-vendors-${vendorId}`)
-
- return {
- success: true,
- message: "파일이 성공적으로 업로드되었습니다."
- }
- } catch (error) {
- console.error("Error uploading file:", error)
- return {
- success: false,
- error: "파일 업로드에 실패했습니다."
- }
- }
-}
-
-export async function getTbeSubmittedFiles(responseId: number) {
- try {
- // First, get the technical response IDs where vendorResponseId matches responseId
- const technicalResponses = await db
- .select({
- id: vendorTechnicalResponses.id,
- })
- .from(vendorTechnicalResponses)
- .where(
- eq(vendorTechnicalResponses.responseId, responseId)
- )
-
- if (technicalResponses.length === 0) {
- return { files: [], error: null }
- }
-
- // Extract the IDs from the result
- const technicalResponseIds = technicalResponses.map(tr => tr.id)
-
- // Then get attachments where technicalResponseId matches any of the IDs we found
- const files = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- })
- .from(vendorResponseAttachments)
- .where(
- inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds)
- )
- .orderBy(vendorResponseAttachments.uploadedAt)
-
- return { files, error: null }
- } catch (error) {
- return { files: [], error: 'Failed to fetch TBE submitted files' }
- }
-}
-
-
-
-export async function getTbeFilesForVendor(rfqId: number, vendorId: number) {
- try {
- // Step 1: Get responseId from vendor_responses table
- const response = await db
- .select({
- id: vendorResponses.id,
- })
- .from(vendorResponses)
- .where(
- and(
- eq(vendorResponses.rfqId, rfqId),
- eq(vendorResponses.vendorId, vendorId)
- )
- )
- .limit(1);
-
- if (!response || response.length === 0) {
- return { files: [], error: 'No vendor response found' };
- }
-
- const responseId = response[0].id;
-
- // Step 2: Get the technical response IDs
- const technicalResponses = await db
- .select({
- id: vendorTechnicalResponses.id,
- })
- .from(vendorTechnicalResponses)
- .where(
- eq(vendorTechnicalResponses.responseId, responseId)
- );
-
- if (technicalResponses.length === 0) {
- return { files: [], error: null };
- }
-
- // Extract the IDs from the result
- const technicalResponseIds = technicalResponses.map(tr => tr.id);
-
- // Step 3: Get attachments where technicalResponseId matches any of the IDs
- const files = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- })
- .from(vendorResponseAttachments)
- .where(
- inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds)
- )
- .orderBy(vendorResponseAttachments.uploadedAt);
-
- return { files, error: null };
- } catch (error) {
- return { files: [], error: 'Failed to fetch vendor files' };
- }
-}
-
-export async function getAllTBE(input: GetTBESchema) {
- return unstable_cache(
- async () => {
- // 1) 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // 2) 고급 필터
- const advancedWhere = filterColumns({
- table: vendorTbeView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // 3) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorTbeView.vendorName} ILIKE ${s}`,
- sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorTbeView.email} ILIKE ${s}`,
- sql`${vendorTbeView.rfqCode} ILIKE ${s}`,
- sql`${vendorTbeView.projectCode} ILIKE ${s}`,
- sql`${vendorTbeView.projectName} ILIKE ${s}`
- )
- }
-
- // 4) REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorTbeView.rfqVendorStatus)
- )
-
- // 5) rfqType 필터 추가
- const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined
-
- // 6) finalWhere - rfqType 필터 추가
- const finalWhere = and(
- notRejected,
- advancedWhere,
- globalWhere,
- rfqTypeFilter // 새로 추가된 rfqType 필터
- )
-
- // 6) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- const col = (vendorTbeView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
- })
- : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first
-
- // 7) 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 원하는 컬럼들
- id: vendorTbeView.vendorId,
- tbeId: vendorTbeView.tbeId,
- vendorId: vendorTbeView.vendorId,
- vendorName: vendorTbeView.vendorName,
- vendorCode: vendorTbeView.vendorCode,
- address: vendorTbeView.address,
- country: vendorTbeView.country,
- email: vendorTbeView.email,
- website: vendorTbeView.website,
- vendorStatus: vendorTbeView.vendorStatus,
-
- rfqId: vendorTbeView.rfqId,
- rfqCode: vendorTbeView.rfqCode,
- projectCode: vendorTbeView.projectCode,
- projectName: vendorTbeView.projectName,
- description: vendorTbeView.description,
- dueDate: vendorTbeView.dueDate,
-
- rfqVendorStatus: vendorTbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
-
- technicalResponseStatus:vendorTbeView.technicalResponseStatus,
- tbeResult: vendorTbeView.tbeResult,
-
- tbeNote: vendorTbeView.tbeNote,
- tbeUpdated: vendorTbeView.tbeUpdated,
- })
- .from(vendorTbeView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorTbeView)
- .where(finalWhere)
-
- return [data, Number(count)]
- })
-
- if (!rows.length) {
- return { data: [], pageCount: 0 }
- }
-
- // 8) Get distinct rfqIds and vendorIds - filter out nulls
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[];
- const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[];
-
- // 9) Comments 조회
- const commentsConditions = [isNotNull(rfqComments.evaluationId)];
-
- // 배열이 비어있지 않을 때만 조건 추가
- if (distinctRfqIds.length > 0) {
- commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds));
- }
-
- if (distinctVendorIds.length > 0) {
- commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds));
- }
-
- const commAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- rfqId: rfqComments.rfqId,
- evaluationId: rfqComments.evaluationId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- evalType: rfqEvaluations.evalType,
- })
- .from(rfqComments)
- .innerJoin(
- rfqEvaluations,
- and(
- eq(rfqEvaluations.id, rfqComments.evaluationId),
- eq(rfqEvaluations.evalType, "TBE")
- )
- )
- .where(and(...commentsConditions));
-
- // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping
- const commByCompositeKey = new Map<string, any[]>()
- for (const c of commAll) {
- if (!c.rfqId || !c.vendorId) continue;
-
- const compositeKey = `${c.rfqId}-${c.vendorId}`;
- if (!commByCompositeKey.has(compositeKey)) {
- commByCompositeKey.set(compositeKey, [])
- }
- commByCompositeKey.get(compositeKey)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- })
- }
-
- // 10) Responses 조회
- const responsesAll = await db
- .select({
- id: vendorResponses.id,
- rfqId: vendorResponses.rfqId,
- vendorId: vendorResponses.vendorId
- })
- .from(vendorResponses)
- .where(
- and(
- inArray(vendorResponses.rfqId, distinctRfqIds),
- inArray(vendorResponses.vendorId, distinctVendorIds)
- )
- );
-
- // Group responses by rfqId-vendorId composite key
- const responsesByCompositeKey = new Map<string, number[]>();
- for (const resp of responsesAll) {
- const compositeKey = `${resp.rfqId}-${resp.vendorId}`;
- if (!responsesByCompositeKey.has(compositeKey)) {
- responsesByCompositeKey.set(compositeKey, []);
- }
- responsesByCompositeKey.get(compositeKey)!.push(resp.id);
- }
-
- // Get all responseIds
- const allResponseIds = responsesAll.map(r => r.id);
-
- // 11) Get technicalResponses for these responseIds
- const technicalResponsesAll = await db
- .select({
- id: vendorTechnicalResponses.id,
- responseId: vendorTechnicalResponses.responseId
- })
- .from(vendorTechnicalResponses)
- .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
-
- // Create mapping from responseId to technicalResponseIds
- const technicalResponseIdsByResponseId = new Map<number, number[]>();
- for (const tr of technicalResponsesAll) {
- if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
- technicalResponseIdsByResponseId.set(tr.responseId, []);
- }
- technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
- }
-
- // Get all technicalResponseIds
- const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
-
- // 12) Get attachments for these technicalResponseIds
- const filesAll = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- technicalResponseId: vendorResponseAttachments.technicalResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
- isNotNull(vendorResponseAttachments.technicalResponseId)
- )
- );
-
- // Create mapping from technicalResponseId to attachments
- const filesByTechnicalResponseId = new Map<number, any[]>();
- for (const file of filesAll) {
- if (file.technicalResponseId === null) continue;
-
- if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
- filesByTechnicalResponseId.set(file.technicalResponseId, []);
- }
- filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy
- });
- }
-
- // 13) Create the final filesByCompositeKey map
- const filesByCompositeKey = new Map<string, any[]>();
-
- for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) {
- filesByCompositeKey.set(compositeKey, []);
-
- for (const responseId of responseIds) {
- const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
-
- for (const technicalResponseId of technicalResponseIds) {
- const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
- filesByCompositeKey.get(compositeKey)!.push(...files);
- }
- }
- }
-
- // 14) 최종 합치기
- const final = rows.map((row) => {
- const compositeKey = `${row.rfqId}-${row.vendorId}`;
-
- return {
- ...row,
- dueDate: row.dueDate ? new Date(row.dueDate) : null,
- comments: commByCompositeKey.get(compositeKey) ?? [],
- files: filesByCompositeKey.get(compositeKey) ?? [],
- };
- })
-
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: ["all-tbe-vendors"],
- }
- )()
-}
-
-
-export async function getCBE(input: GetCBESchema, rfqId: number) {
- return unstable_cache(
- async () => {
- // [1] 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
- const limit = input.perPage ?? 10;
-
- // [2] 고급 필터
- const advancedWhere = filterColumns({
- table: vendorResponseCBEView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- });
-
- // [3] 글로벌 검색
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`,
- sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
- );
- }
-
- // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음)
- const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
-
- // [5] 최종 where 조건
- const finalWhere = and(
- eq(vendorResponseCBEView.rfqId, rfqId),
- notDeclined,
- advancedWhere ?? undefined,
- globalWhere ?? undefined
- );
-
- // [6] 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
- const col = (vendorResponseCBEView as any)[s.id];
- return s.desc ? desc(col) : asc(col);
- })
- : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명
-
- // [7] 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 기본 식별 정보
- responseId: vendorResponseCBEView.responseId,
- vendorId: vendorResponseCBEView.vendorId,
- rfqId: vendorResponseCBEView.rfqId,
-
- // 협력업체 정보
- vendorName: vendorResponseCBEView.vendorName,
- vendorCode: vendorResponseCBEView.vendorCode,
- vendorStatus: vendorResponseCBEView.vendorStatus,
-
- // RFQ 정보
- rfqCode: vendorResponseCBEView.rfqCode,
- rfqDescription: vendorResponseCBEView.rfqDescription,
- rfqDueDate: vendorResponseCBEView.rfqDueDate,
- rfqStatus: vendorResponseCBEView.rfqStatus,
- rfqType: vendorResponseCBEView.rfqType,
-
- // 프로젝트 정보
- projectId: vendorResponseCBEView.projectId,
- projectCode: vendorResponseCBEView.projectCode,
- projectName: vendorResponseCBEView.projectName,
-
- // 응답 상태 정보
- responseStatus: vendorResponseCBEView.responseStatus,
- responseNotes: vendorResponseCBEView.notes,
- respondedAt: vendorResponseCBEView.respondedAt,
- respondedBy: vendorResponseCBEView.respondedBy,
-
- // 상업 응답 정보
- commercialResponseId: vendorResponseCBEView.commercialResponseId,
- commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
- totalPrice: vendorResponseCBEView.totalPrice,
- currency: vendorResponseCBEView.currency,
- paymentTerms: vendorResponseCBEView.paymentTerms,
- incoterms: vendorResponseCBEView.incoterms,
- deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
- warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
- validityPeriod: vendorResponseCBEView.validityPeriod,
- commercialNotes: vendorResponseCBEView.commercialNotes,
-
- // 첨부파일 카운트
- attachmentCount: vendorResponseCBEView.attachmentCount,
- commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
- technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
- })
- .from(vendorResponseCBEView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorResponseCBEView)
- .where(finalWhere);
-
- return [data, Number(count)];
- });
-
- if (!rows.length) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- // [8] 협력업체 ID 목록 추출
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))];
- const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))];
- const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
-
- // [9] CBE 평가 관련 코멘트 조회
- const commentsAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- cbeId: rfqComments.cbeId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- })
- .from(rfqComments)
- .innerJoin(
- vendorResponses,
- eq(vendorResponses.id, rfqComments.cbeId)
- )
- .where(
- and(
- isNotNull(rfqComments.cbeId),
- eq(rfqComments.rfqId, rfqId),
- inArray(rfqComments.vendorId, distinctVendorIds)
- )
- );
-
- // vendorId별 코멘트 그룹화
- const commentsByVendorId = new Map<number, any[]>();
- for (const comment of commentsAll) {
- const vendorId = comment.vendorId!;
- if (!commentsByVendorId.has(vendorId)) {
- commentsByVendorId.set(vendorId, []);
- }
- commentsByVendorId.get(vendorId)!.push({
- id: comment.id,
- commentText: comment.commentText,
- vendorId: comment.vendorId,
- cbeId: comment.cbeId,
- createdAt: comment.createdAt,
- commentedBy: comment.commentedBy,
- });
- }
-
- // [10] 첨부 파일 조회 - 일반 응답 첨부파일
- const responseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- responseId: vendorResponseAttachments.responseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.responseId, distinctResponseIds),
- isNotNull(vendorResponseAttachments.responseId)
- )
- );
-
- // [11] 첨부 파일 조회 - 상업 응답 첨부파일
- const commercialResponseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- commercialResponseId: vendorResponseAttachments.commercialResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
- isNotNull(vendorResponseAttachments.commercialResponseId)
- )
- );
-
- // [12] 첨부파일 그룹화
- // responseId별 첨부파일 맵 생성
- const filesByResponseId = new Map<number, any[]>();
- for (const file of responseAttachments) {
- const responseId = file.responseId!;
- if (!filesByResponseId.has(responseId)) {
- filesByResponseId.set(responseId, []);
- }
- filesByResponseId.get(responseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'response'
- });
- }
-
- // commercialResponseId별 첨부파일 맵 생성
- const filesByCommercialResponseId = new Map<number, any[]>();
- for (const file of commercialResponseAttachments) {
- const commercialResponseId = file.commercialResponseId!;
- if (!filesByCommercialResponseId.has(commercialResponseId)) {
- filesByCommercialResponseId.set(commercialResponseId, []);
- }
- filesByCommercialResponseId.get(commercialResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'commercial'
- });
- }
-
- // [13] 최종 데이터 병합
- const final = rows.map((row) => {
- // 해당 응답의 모든 첨부파일 가져오기
- const responseFiles = filesByResponseId.get(row.responseId) || [];
- const commercialFiles = row.commercialResponseId
- ? filesByCommercialResponseId.get(row.commercialResponseId) || []
- : [];
-
- // 모든 첨부파일 병합
- const allFiles = [...responseFiles, ...commercialFiles];
-
- return {
- ...row,
- rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
- respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
- comments: commentsByVendorId.get(row.vendorId) || [],
- files: allFiles,
- };
- });
-
- const pageCount = Math.ceil(total / limit);
- return {
- data: final,
- pageCount,
- total
- };
- },
- // 캐싱 키 & 옵션
- [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: [`cbe-vendors-${rfqId}`],
- }
- )();
-}
-
-export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> {
- try {
- if (!rfqType) {
- return { code: "", error: 'RFQ 타입이 필요합니다' };
- }
-
- // 현재 연도 가져오기
- const currentYear = new Date().getFullYear();
-
- // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기
- const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode })
- .from(rfqs)
- .where(and(
- sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`,
- eq(rfqs.rfqType, rfqType)
- ))
- .orderBy(desc(rfqs.rfqCode))
- .limit(1);
-
- let sequenceNumber = 1;
-
- if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) {
- // null 체크 추가 - TypeScript 오류 해결
- const latestCode = latestRfqs[0].rfqCode;
- const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/);
-
- if (matches && matches[1]) {
- sequenceNumber = parseInt(matches[1], 10) + 1;
- }
- }
-
- // 새로운 RFQ 코드 포맷팅
- const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' :
- rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ';
-
- const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`;
-
- return { code: newCode };
- } catch (error) {
- console.error('Error generating next RFQ code:', error);
- return { code: "", error: '코드 생성에 실패했습니다' };
- }
-}
-
-interface SaveTbeResultParams {
- id: number // id from the rfq_evaluations table
- vendorId: number // vendorId from the rfq_evaluations table
- result: string // The selected evaluation result
- notes: string // The evaluation notes
-}
-
-export async function saveTbeResult({
- id,
- vendorId,
- result,
- notes,
-}: SaveTbeResultParams) {
- try {
- // Check if we have all required data
- if (!id || !vendorId || !result) {
- return {
- success: false,
- message: "Missing required data for evaluation update",
- }
- }
-
- // Update the record in the database
- await db
- .update(rfqEvaluations)
- .set({
- result: result,
- notes: notes,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(rfqEvaluations.id, id),
- eq(rfqEvaluations.vendorId, vendorId),
- eq(rfqEvaluations.evalType, "TBE")
- )
- )
-
- // Revalidate the tbe-vendors tag to refresh the data
- revalidateTag("tbe-vendors")
- revalidateTag("all-tbe-vendors")
-
- return {
- success: true,
- message: "TBE evaluation updated successfully",
- }
- } catch (error) {
- console.error("Failed to update TBE evaluation:", error)
-
- return {
- success: false,
- message: error instanceof Error ? error.message : "An unknown error occurred",
- }
- }
-}
-
-
-export async function createCbeEvaluation(formData: FormData) {
- try {
- // 폼 데이터 추출
- const rfqId = Number(formData.get("rfqId"))
- const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id))
- const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null
-
-
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
-
- // 기본 CBE 데이터 추출
- const rawData = {
- rfqId,
- paymentTerms: formData.get("paymentTerms") as string,
- incoterms: formData.get("incoterms") as string,
- deliverySchedule: formData.get("deliverySchedule") as string,
- notes: formData.get("notes") as string,
- // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음)
- // vendorId: vendorIds[0] || 0,
- }
-
- // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리)
- const validationResult = createCbeEvaluationSchema.safeParse(rawData)
- if (!validationResult.success) {
- const errors = validationResult.error.format()
- console.error("Validation errors:", errors)
- return { error: "입력 데이터가 유효하지 않습니다." }
- }
-
- const validData = validationResult.data
-
- // RFQ 정보 조회
- const [rfqInfo] = await db
- .select({
- rfqCode: rfqsView.rfqCode,
- projectCode: rfqsView.projectCode,
- projectName: rfqsView.projectName,
- dueDate: rfqsView.dueDate,
- description: rfqsView.description,
- })
- .from(rfqsView)
- .where(eq(rfqsView.id, rfqId))
-
- if (!rfqInfo) {
- return { error: "RFQ 정보를 찾을 수 없습니다." }
- }
-
- // 파일 처리 준비
- const files = formData.getAll("files") as File[]
- const hasFiles = files && files.length > 0 && files[0].size > 0
-
-
- // 첨부 파일 정보를 저장할 배열
- const attachments: { filename: string; path: string }[] = []
-
- // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비
- if (hasFiles) {
- for (const file of files) {
- if (file.size > 0) {
- const originalFilename = file.name
- const fileExtension = path.extname(originalFilename)
- const timestamp = new Date().getTime()
- const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}`
- const filePath = path.join("rfq", String(rfqId), safeFilename)
- const fullPath = path.join(process.cwd(), "public", filePath)
-
- const saveResult = await saveFile({file, directory:'rfq'})
-
- }
- }
- }
-
- // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송
- const createdCbeIds: number[] = []
- const failedVendors: { id: number, reason: string }[] = []
-
- for (const vendorId of vendorIds) {
- try {
- // 협력업체 정보 조회 (이메일 포함)
- const [vendorInfo] = await db
- .select({
- id: vendors.id,
- name: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- email: vendors.email, // 협력업체 자체 이메일 추가
- representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
-
- if (!vendorInfo) {
- failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." })
- continue
- }
-
- // 기존 협력업체 응답 레코드 찾기
- const existingResponse = await db
- .select({ id: vendorResponses.id })
- .from(vendorResponses)
- .where(
- and(
- eq(vendorResponses.rfqId, rfqId),
- eq(vendorResponses.vendorId, vendorId)
- )
- )
- .limit(1)
-
- if (existingResponse.length === 0) {
- console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`)
- failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" })
- continue // 다음 벤더로 넘어감
- }
-
- // 1. CBE 평가 레코드 생성
- const [newCbeEvaluation] = await db
- .insert(cbeEvaluations)
- .values({
- rfqId,
- vendorId,
- evaluatedBy,
- result: "PENDING", // 초기 상태는 PENDING으로 설정
- totalCost: 0, // 초기값은 0으로 설정
- currency: "USD", // 기본 통화 설정
- paymentTerms: validData.paymentTerms || null,
- incoterms: validData.incoterms || null,
- deliverySchedule: validData.deliverySchedule || null,
- notes: validData.notes || null,
- })
- .returning({ id: cbeEvaluations.id })
-
- if (!newCbeEvaluation?.id) {
- failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" })
- continue
- }
-
- // 2. 상업 응답 레코드 생성
- const [newCbeResponse] = await db
- .insert(vendorCommercialResponses)
- .values({
- responseId: existingResponse[0].id,
- responseStatus: "PENDING",
- currency: "USD",
- paymentTerms: validData.paymentTerms || null,
- incoterms: validData.incoterms || null,
- deliveryPeriod: validData.deliverySchedule || null,
- })
- .returning({ id: vendorCommercialResponses.id })
-
- if (!newCbeResponse?.id) {
- failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" })
- continue
- }
-
- createdCbeIds.push(newCbeEvaluation.id)
-
- // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성
- if (hasFiles) {
- for (let i = 0; i < attachments.length; i++) {
- const attachment = attachments[i]
-
- await db.insert(rfqAttachments).values({
- rfqId,
- vendorId,
- fileName: attachment.filename,
- filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장
- cbeId: newCbeEvaluation.id,
- })
- }
- }
-
- // 4. 협력업체 연락처 조회
- const contacts = await db
- .select({
- contactName: vendorContacts.contactName,
- contactEmail: vendorContacts.contactEmail,
- isPrimary: vendorContacts.isPrimary,
- })
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendorId))
-
- // 5. 모든 이메일 주소 수집 및 중복 제거
- const allEmails = new Set<string>()
-
- // 연락처 이메일 추가
- contacts.forEach(contact => {
- if (contact.contactEmail) {
- allEmails.add(contact.contactEmail.trim().toLowerCase())
- }
- })
-
- // 협력업체 자체 이메일 추가 (있는 경우에만)
- if (vendorInfo.email) {
- allEmails.add(vendorInfo.email.trim().toLowerCase())
- }
-
- // 협력업체 대표자 이메일 추가 (있는 경우에만)
- if (vendorInfo.representativeEmail) {
- allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase())
- }
-
- // 중복이 제거된 이메일 주소 배열로 변환
- const uniqueEmails = Array.from(allEmails)
-
- if (uniqueEmails.length === 0) {
- console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`)
- } else {
- console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`)
-
- // 이메일 발송에 필요한 공통 데이터 준비
- const emailData = {
- rfqId,
- cbeId: newCbeEvaluation.id,
- vendorId,
- rfqCode: rfqInfo.rfqCode,
- projectCode: rfqInfo.projectCode,
- projectName: rfqInfo.projectName,
- dueDate: rfqInfo.dueDate,
- description: rfqInfo.description,
- vendorName: vendorInfo.name,
- vendorCode: vendorInfo.vendorCode,
- paymentTerms: validData.paymentTerms,
- incoterms: validData.incoterms,
- deliverySchedule: validData.deliverySchedule,
- notes: validData.notes,
- loginUrl: `http://${host}/en/partners/cbe`
- }
-
- // 각 고유 이메일 주소로 이메일 발송
- for (const email of uniqueEmails) {
- try {
- // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체)
- const contact = contacts.find(c =>
- c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase()
- )
- const contactName = contact?.contactName || `${vendorInfo.name} 담당자`
-
- await sendEmail({
- to: email,
- subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`,
- template: "cbe-invitation",
- context: {
- language: "ko", // 또는 다국어 처리를 위한 설정
- contactName,
- ...emailData,
- },
- attachments: attachments,
- })
- console.log(`이메일 전송 성공: ${email}`)
- } catch (emailErr) {
- console.error(`이메일 전송 실패 (${email}):`, emailErr)
- }
- }
- }
-
- } catch (err) {
- console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err)
- failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" })
- }
- }
-
- // UI 업데이트를 위한 경로 재검증
- revalidatePath(`/rfq/${rfqId}`)
- revalidateTag(`cbe-vendors-${rfqId}`)
-
- // 결과 반환
- if (createdCbeIds.length === 0) {
- return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." }
- }
-
- return {
- success: true,
- cbeIds: createdCbeIds,
- totalCreated: createdCbeIds.length,
- totalFailed: failedVendors.length,
- failedVendors: failedVendors.length > 0 ? failedVendors : undefined
- }
-
- } catch (error) {
- console.error("CBE 평가 생성 중 오류 발생:", error)
- return { error: "예상치 못한 오류가 발생했습니다." }
- }
-}
-
-export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) {
- return unstable_cache(
- async () => {
- // [1] 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
- const limit = input.perPage ?? 10;
-
- // [2] 고급 필터
- const advancedWhere = filterColumns({
- table: vendorResponseCBEView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- });
-
- // [3] 글로벌 검색
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.projectName} ILIKE ${s}`,
- sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
- );
- }
-
- // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음)
- // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
-
- // [5] 최종 where 조건
- const finalWhere = and(
- eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링
- isNotNull(vendorResponseCBEView.commercialCreatedAt),
- // notDeclined,
- advancedWhere ?? undefined,
- globalWhere ?? undefined
- );
-
- // [6] 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
- const col = (vendorResponseCBEView as any)[s.id];
- return s.desc ? desc(col) : asc(col);
- })
- : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순
-
- // [7] 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 기본 식별 정보
- responseId: vendorResponseCBEView.responseId,
- vendorId: vendorResponseCBEView.vendorId,
- rfqId: vendorResponseCBEView.rfqId,
-
- // 협력업체 정보
- vendorName: vendorResponseCBEView.vendorName,
- vendorCode: vendorResponseCBEView.vendorCode,
- vendorStatus: vendorResponseCBEView.vendorStatus,
-
- // RFQ 정보
- rfqCode: vendorResponseCBEView.rfqCode,
- rfqDescription: vendorResponseCBEView.rfqDescription,
- rfqDueDate: vendorResponseCBEView.rfqDueDate,
- rfqStatus: vendorResponseCBEView.rfqStatus,
- rfqType: vendorResponseCBEView.rfqType,
-
- // 프로젝트 정보
- projectId: vendorResponseCBEView.projectId,
- projectCode: vendorResponseCBEView.projectCode,
- projectName: vendorResponseCBEView.projectName,
-
- // 응답 상태 정보
- responseStatus: vendorResponseCBEView.responseStatus,
- responseNotes: vendorResponseCBEView.notes,
- respondedAt: vendorResponseCBEView.respondedAt,
- respondedBy: vendorResponseCBEView.respondedBy,
-
- // 상업 응답 정보
- commercialResponseId: vendorResponseCBEView.commercialResponseId,
- commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
- totalPrice: vendorResponseCBEView.totalPrice,
- currency: vendorResponseCBEView.currency,
- paymentTerms: vendorResponseCBEView.paymentTerms,
- incoterms: vendorResponseCBEView.incoterms,
- deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
- warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
- validityPeriod: vendorResponseCBEView.validityPeriod,
- commercialNotes: vendorResponseCBEView.commercialNotes,
-
- // 첨부파일 카운트
- attachmentCount: vendorResponseCBEView.attachmentCount,
- commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
- technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
- })
- .from(vendorResponseCBEView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorResponseCBEView)
- .where(finalWhere);
-
- return [data, Number(count)];
- });
-
- if (!rows.length) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- // [8] RFQ ID 목록 추출
- const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))];
- const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))];
- const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
-
- // [9] CBE 평가 관련 코멘트 조회
- const commentsAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- rfqId: rfqComments.rfqId,
- cbeId: rfqComments.cbeId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- })
- .from(rfqComments)
- .innerJoin(
- vendorResponses,
- eq(vendorResponses.id, rfqComments.cbeId)
- )
- .where(
- and(
- isNotNull(rfqComments.cbeId),
- eq(rfqComments.vendorId, vendorId),
- inArray(rfqComments.rfqId, distinctRfqIds)
- )
- );
-
- // rfqId별 코멘트 그룹화
- const commentsByRfqId = new Map<number, any[]>();
- for (const comment of commentsAll) {
- const rfqId = comment.rfqId!;
- if (!commentsByRfqId.has(rfqId)) {
- commentsByRfqId.set(rfqId, []);
- }
- commentsByRfqId.get(rfqId)!.push({
- id: comment.id,
- commentText: comment.commentText,
- rfqId: comment.rfqId,
- cbeId: comment.cbeId,
- createdAt: comment.createdAt,
- commentedBy: comment.commentedBy,
- });
- }
-
- // [10] 첨부 파일 조회 - 일반 응답 첨부파일
- const responseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- responseId: vendorResponseAttachments.responseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.responseId, distinctResponseIds),
- isNotNull(vendorResponseAttachments.responseId)
- )
- );
-
- // [11] 첨부 파일 조회 - 상업 응답 첨부파일
- const commercialResponseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- commercialResponseId: vendorResponseAttachments.commercialResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
- isNotNull(vendorResponseAttachments.commercialResponseId)
- )
- );
-
- // [12] 첨부파일 그룹화
- // responseId별 첨부파일 맵 생성
- const filesByResponseId = new Map<number, any[]>();
- for (const file of responseAttachments) {
- const responseId = file.responseId!;
- if (!filesByResponseId.has(responseId)) {
- filesByResponseId.set(responseId, []);
- }
- filesByResponseId.get(responseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'response'
- });
- }
-
- // commercialResponseId별 첨부파일 맵 생성
- const filesByCommercialResponseId = new Map<number, any[]>();
- for (const file of commercialResponseAttachments) {
- const commercialResponseId = file.commercialResponseId!;
- if (!filesByCommercialResponseId.has(commercialResponseId)) {
- filesByCommercialResponseId.set(commercialResponseId, []);
- }
- filesByCommercialResponseId.get(commercialResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'commercial'
- });
- }
-
- // [13] 최종 데이터 병합
- const final = rows.map((row) => {
- // 해당 응답의 모든 첨부파일 가져오기
- const responseFiles = filesByResponseId.get(row.responseId) || [];
- const commercialFiles = row.commercialResponseId
- ? filesByCommercialResponseId.get(row.commercialResponseId) || []
- : [];
-
- // 모든 첨부파일 병합
- const allFiles = [...responseFiles, ...commercialFiles];
-
- return {
- ...row,
- rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
- respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
- comments: commentsByRfqId.get(row.rfqId) || [],
- files: allFiles,
- };
- });
-
- const pageCount = Math.ceil(total / limit);
- return {
- data: final,
- pageCount,
- total
- };
- },
- // 캐싱 키 & 옵션
- [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: [`cbe-vendor-${vendorId}`],
- }
- )();
-}
-
-export async function fetchCbeFiles(vendorId: number, rfqId: number) {
- try {
- // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다.
- const cbeEval = await db
- .select({ id: cbeEvaluations.id })
- .from(cbeEvaluations)
- .where(
- and(
- eq(cbeEvaluations.rfqId, rfqId),
- eq(cbeEvaluations.vendorId, vendorId)
- )
- )
- .limit(1)
-
- if (!cbeEval.length) {
- return {
- files: [],
- error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다."
- }
- }
-
- const cbeId = cbeEval[0].id
-
- // 2. 관련 첨부 파일을 조회합니다.
- // - commentId와 evaluationId는 null이어야 함
- // - rfqId와 vendorId가 일치해야 함
- // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함
- const files = await db
- .select({
- id: rfqAttachments.id,
- fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath,
- createdAt: rfqAttachments.createdAt
- })
- .from(rfqAttachments)
- .where(
- and(
- eq(rfqAttachments.rfqId, rfqId),
- eq(rfqAttachments.vendorId, vendorId),
- eq(rfqAttachments.cbeId, cbeId),
- isNull(rfqAttachments.commentId),
- isNull(rfqAttachments.evaluationId)
- )
- )
- .orderBy(rfqAttachments.createdAt)
-
- return {
- files,
- cbeId
- }
- } catch (error) {
- console.error("CBE 파일 조회 중 오류 발생:", error)
- return {
- files: [],
- error: "CBE 파일을 가져오는 중 오류가 발생했습니다."
- }
- }
-}
-
-export async function getAllCBE(input: GetCBESchema) {
- return unstable_cache(
- async () => {
- // [1] 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
- const limit = input.perPage ?? 10;
-
- // [2] 고급 필터
- const advancedWhere = filterColumns({
- table: vendorResponseCBEView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- });
-
- // [3] 글로벌 검색
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`,
- sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`,
- sql`${vendorResponseCBEView.projectName} ILIKE ${s}`,
- sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
- );
- }
-
- // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음)
- const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
-
- // [5] rfqType 필터 추가
- const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined;
-
- // [6] 최종 where 조건
- const finalWhere = and(
- notDeclined,
- advancedWhere ?? undefined,
- globalWhere ?? undefined,
- rfqTypeFilter // 새로 추가된 rfqType 필터
- );
-
- // [7] 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
- const col = (vendorResponseCBEView as any)[s.id];
- return s.desc ? desc(col) : asc(col);
- })
- : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명
-
- // [8] 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 기본 식별 정보
- responseId: vendorResponseCBEView.responseId,
- vendorId: vendorResponseCBEView.vendorId,
- rfqId: vendorResponseCBEView.rfqId,
-
- // 협력업체 정보
- vendorName: vendorResponseCBEView.vendorName,
- vendorCode: vendorResponseCBEView.vendorCode,
- vendorStatus: vendorResponseCBEView.vendorStatus,
-
- // RFQ 정보
- rfqCode: vendorResponseCBEView.rfqCode,
- rfqDescription: vendorResponseCBEView.rfqDescription,
- rfqDueDate: vendorResponseCBEView.rfqDueDate,
- rfqStatus: vendorResponseCBEView.rfqStatus,
- rfqType: vendorResponseCBEView.rfqType,
-
- // 프로젝트 정보
- projectId: vendorResponseCBEView.projectId,
- projectCode: vendorResponseCBEView.projectCode,
- projectName: vendorResponseCBEView.projectName,
-
- // 응답 상태 정보
- responseStatus: vendorResponseCBEView.responseStatus,
- responseNotes: vendorResponseCBEView.notes,
- respondedAt: vendorResponseCBEView.respondedAt,
- respondedBy: vendorResponseCBEView.respondedBy,
-
- // 상업 응답 정보
- commercialResponseId: vendorResponseCBEView.commercialResponseId,
- commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
- totalPrice: vendorResponseCBEView.totalPrice,
- currency: vendorResponseCBEView.currency,
- paymentTerms: vendorResponseCBEView.paymentTerms,
- incoterms: vendorResponseCBEView.incoterms,
- deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
- warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
- validityPeriod: vendorResponseCBEView.validityPeriod,
- commercialNotes: vendorResponseCBEView.commercialNotes,
-
- // 첨부파일 카운트
- attachmentCount: vendorResponseCBEView.attachmentCount,
- commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
- technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
- })
- .from(vendorResponseCBEView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorResponseCBEView)
- .where(finalWhere);
-
- return [data, Number(count)];
- });
-
- if (!rows.length) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[];
- const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[];
- const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[];
- const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
-
- // [10] CBE 평가 관련 코멘트 조회
- const commentsConditions = [isNotNull(rfqComments.cbeId)];
-
- // 배열이 비어있지 않을 때만 조건 추가
- if (distinctRfqIds.length > 0) {
- commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds));
- }
-
- if (distinctVendorIds.length > 0) {
- commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds));
- }
-
- const commentsAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- rfqId: rfqComments.rfqId,
- cbeId: rfqComments.cbeId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- })
- .from(rfqComments)
- .innerJoin(
- vendorResponses,
- eq(vendorResponses.id, rfqComments.cbeId)
- )
- .where(and(...commentsConditions));
-
- // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화
- const commentsByCompositeKey = new Map<string, any[]>();
- for (const comment of commentsAll) {
- if (!comment.rfqId || !comment.vendorId) continue;
-
- const compositeKey = `${comment.rfqId}-${comment.vendorId}`;
- if (!commentsByCompositeKey.has(compositeKey)) {
- commentsByCompositeKey.set(compositeKey, []);
- }
- commentsByCompositeKey.get(compositeKey)!.push({
- id: comment.id,
- commentText: comment.commentText,
- vendorId: comment.vendorId,
- cbeId: comment.cbeId,
- createdAt: comment.createdAt,
- commentedBy: comment.commentedBy,
- });
- }
-
- // [12] 첨부 파일 조회 - 일반 응답 첨부파일
- const responseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- responseId: vendorResponseAttachments.responseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.responseId, distinctResponseIds),
- isNotNull(vendorResponseAttachments.responseId)
- )
- );
-
- // [13] 첨부 파일 조회 - 상업 응답 첨부파일
- const commercialResponseAttachments = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- commercialResponseId: vendorResponseAttachments.commercialResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy,
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
- isNotNull(vendorResponseAttachments.commercialResponseId)
- )
- );
-
- // [14] 첨부파일 그룹화
- // responseId별 첨부파일 맵 생성
- const filesByResponseId = new Map<number, any[]>();
- for (const file of responseAttachments) {
- const responseId = file.responseId!;
- if (!filesByResponseId.has(responseId)) {
- filesByResponseId.set(responseId, []);
- }
- filesByResponseId.get(responseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'response'
- });
- }
-
- // commercialResponseId별 첨부파일 맵 생성
- const filesByCommercialResponseId = new Map<number, any[]>();
- for (const file of commercialResponseAttachments) {
- const commercialResponseId = file.commercialResponseId!;
- if (!filesByCommercialResponseId.has(commercialResponseId)) {
- filesByCommercialResponseId.set(commercialResponseId, []);
- }
- filesByCommercialResponseId.get(commercialResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy,
- attachmentSource: 'commercial'
- });
- }
-
- // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성
- const filesByCompositeKey = new Map<string, any[]>();
-
- // responseId -> rfqId-vendorId 매핑 생성
- const responseIdToCompositeKey = new Map<number, string>();
- for (const row of rows) {
- if (row.responseId) {
- responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`);
- }
- if (row.commercialResponseId) {
- responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`);
- }
- }
-
- // responseId별 첨부파일을 복합 키별로 그룹화
- for (const [responseId, files] of filesByResponseId.entries()) {
- const compositeKey = responseIdToCompositeKey.get(responseId);
- if (compositeKey) {
- if (!filesByCompositeKey.has(compositeKey)) {
- filesByCompositeKey.set(compositeKey, []);
- }
- filesByCompositeKey.get(compositeKey)!.push(...files);
- }
- }
-
- // commercialResponseId별 첨부파일을 복합 키별로 그룹화
- for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) {
- const compositeKey = responseIdToCompositeKey.get(commercialResponseId);
- if (compositeKey) {
- if (!filesByCompositeKey.has(compositeKey)) {
- filesByCompositeKey.set(compositeKey, []);
- }
- filesByCompositeKey.get(compositeKey)!.push(...files);
- }
- }
-
- // [16] 최종 데이터 병합
- const final = rows.map((row) => {
- const compositeKey = `${row.rfqId}-${row.vendorId}`;
-
- return {
- ...row,
- rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
- respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
- comments: commentsByCompositeKey.get(compositeKey) || [],
- files: filesByCompositeKey.get(compositeKey) || [],
- };
- });
-
- const pageCount = Math.ceil(total / limit);
- return {
- data: final,
- pageCount,
- total
- };
- },
- // 캐싱 키 & 옵션
- [`all-cbe-vendors-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: ["all-cbe-vendors"],
- }
- )();
-} \ No newline at end of file
diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx
deleted file mode 100644
index 3d822499..00000000
--- a/lib/rfqs/table/ItemsDialog.tsx
+++ /dev/null
@@ -1,752 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray, useWatch } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage
-} from "@/components/ui/form"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import {
- Command,
- CommandInput,
- CommandList,
- CommandItem,
- CommandGroup,
- CommandEmpty
-} from "@/components/ui/command"
-import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react"
-import { toast } from "sonner"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { Badge } from "@/components/ui/badge"
-
-import { createRfqItem, deleteRfqItem } from "../service"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-import { RfqType } from "../validations"
-
-// Zod 스키마 - 수량은 string으로 받아서 나중에 변환
-const itemSchema = z.object({
- id: z.number().optional(),
- itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }),
- description: z.string().optional(),
- quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1),
- uom: z.string().default("each"),
-});
-
-const itemsFormSchema = z.object({
- rfqId: z.number().int(),
- items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }),
-});
-
-type ItemsFormSchema = z.infer<typeof itemsFormSchema>;
-
-interface RfqsItemsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- rfq: RfqWithItemCount | null;
- defaultItems?: {
- id?: number;
- itemCode: string;
- quantity?: number | null;
- description?: string | null;
- uom?: string | null;
- }[];
- itemsList: { code: string | null; name: string }[];
- rfqType?: RfqType;
-}
-
-export function RfqsItemsDialog({
- open,
- onOpenChange,
- rfq,
- defaultItems = [],
- itemsList,
- rfqType
-}: RfqsItemsDialogProps) {
- const rfqId = rfq?.rfqId ?? 0;
-
- // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
- const isEditable = rfq?.status === "DRAFT";
-
- // 초기 아이템 ID 목록을 추적하기 위한 상태 추가
- const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]);
-
- // 삭제된 아이템 ID를 저장하는 상태 추가
- const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]);
-
- // 1) form
- const form = useForm<ItemsFormSchema>({
- resolver: zodResolver(itemsFormSchema),
- defaultValues: {
- rfqId,
- items: defaultItems.length > 0 ? defaultItems.map((it) => ({
- id: it.id,
- quantity: it.quantity ?? 1,
- uom: it.uom ?? "each",
- itemCode: it.itemCode ?? "",
- description: it.description ?? "",
- })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }],
- },
- mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사
- });
-
- // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장
- React.useEffect(() => {
- if (open) {
- const initialItems = defaultItems.length > 0
- ? defaultItems.map((it) => ({
- id: it.id,
- quantity: it.quantity ?? 1,
- uom: it.uom ?? "each",
- itemCode: it.itemCode ?? "",
- description: it.description ?? "",
- }))
- : [{ itemCode: "", description: "", quantity: 1, uom: "each" }];
-
- form.reset({
- rfqId,
- items: initialItems,
- });
-
- // 초기 아이템 ID 목록 저장
- setInitialItemIds(defaultItems.map(item => item.id));
-
- // 삭제된 아이템 목록 초기화
- setDeletedItemIds([]);
- setHasUnsavedChanges(false);
- }
- }, [open, defaultItems, rfqId, form]);
-
- // 새로운 요소에 대한 ref 배열
- const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
- const [isSubmitting, setIsSubmitting] = React.useState(false);
- const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
- const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false);
-
- // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지
- React.useEffect(() => {
- if (!isEditable) return;
-
- const subscription = form.watch(() => {
- setHasUnsavedChanges(true);
- });
- return () => subscription.unsubscribe();
- }, [form, isEditable]);
-
- // 2) field array
- const { fields, append, remove } = useFieldArray({
- control: form.control,
- name: "items",
- });
-
- // 3) watch items array
- const watchItems = form.watch("items");
-
- // 4) Add item row with auto-focus
- function handleAddItem() {
- if (!isEditable) return;
-
- // 명시적으로 숫자 타입으로 지정
- append({
- itemCode: "",
- description: "",
- quantity: 1,
- uom: "each"
- });
- setHasUnsavedChanges(true);
-
- // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스
- setTimeout(() => {
- const newIndex = fields.length;
- const button = inputRefs.current[newIndex];
- if (button) {
- button.click();
- }
- }, 100);
- }
-
- // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가
- const handleRemoveItem = (index: number) => {
- if (!isEditable) return;
-
- const itemToRemove = form.getValues().items[index];
-
- // 기존 ID가 있는 아이템이라면 삭제 목록에 추가
- if (itemToRemove.id !== undefined) {
- setDeletedItemIds(prev => [...prev, itemToRemove.id as number]);
- }
-
- remove(index);
- setHasUnsavedChanges(true);
-
- // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로
- setTimeout(() => {
- const nextIndex = Math.min(index, fields.length - 1);
- if (nextIndex >= 0 && inputRefs.current[nextIndex]) {
- inputRefs.current[nextIndex]?.click();
- }
- }, 50);
- };
-
- // 다이얼로그 닫기 전 확인
- const handleDialogClose = (open: boolean) => {
- if (!open && hasUnsavedChanges && isEditable) {
- setIsExitDialogOpen(true);
- } else {
- onOpenChange(open);
- }
- };
-
- // 필드 포커스 유틸리티 함수
- const focusField = (selector: string) => {
- if (!isEditable) return;
-
- setTimeout(() => {
- const element = document.querySelector(selector) as HTMLInputElement | null;
- if (element) {
- element.focus();
- }
- }, 10);
- };
-
- // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리)
- async function onSubmit(data: ItemsFormSchema) {
- if (!isEditable) return;
-
- try {
- setIsSubmitting(true);
-
- // 각 아이템이 유효한지 확인
- const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1);
-
- if (anyInvalidItems) {
- toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요.");
- setIsSubmitting(false);
- return;
- }
-
- // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청
- const deletePromises = deletedItemIds.map(id =>
- deleteRfqItem({
- id: id,
- rfqId: rfqId,
- rfqType: rfqType ?? RfqType.PURCHASE
- })
- );
-
- // 2. 생성/수정 처리 - 폼에 남아있는 아이템들
- const upsertPromises = data.items.map((item) =>
- createRfqItem({
- rfqId: rfqId,
- itemCode: item.itemCode,
- description: item.description,
- // 명시적으로 숫자로 변환
- quantity: Number(item.quantity),
- uom: item.uom,
- rfqType: rfqType ?? RfqType.PURCHASE,
- id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성
- })
- );
-
- // 모든 요청 병렬 처리
- await Promise.all([...deletePromises, ...upsertPromises]);
-
- toast.success("RFQ 아이템이 성공적으로 저장되었습니다!");
- setHasUnsavedChanges(false);
- onOpenChange(false);
- } catch (err) {
- toast.error(`오류가 발생했습니다: ${String(err)}`);
- } finally {
- setIsSubmitting(false);
- }
- }
-
- // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화
- React.useEffect(() => {
- if (!isEditable) return;
-
- const handleKeyDown = (e: KeyboardEvent) => {
- // Alt+N: 새 항목 추가
- if (e.altKey && e.key === 'n') {
- e.preventDefault();
- handleAddItem();
- }
- // Ctrl+S: 저장
- if ((e.ctrlKey || e.metaKey) && e.key === 's') {
- e.preventDefault();
- form.handleSubmit(onSubmit)();
- }
- // Esc: 포커스된 팝오버 닫기
- if (e.key === 'Escape') {
- document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach(
- (el) => (el as HTMLButtonElement).click()
- );
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [form, isEditable]);
-
- return (
- <>
- <Dialog open={open} onOpenChange={handleDialogClose}>
- <DialogContent className="max-w-none w-[1200px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"}
- <Badge variant="outline" className="ml-2">
- {rfq?.rfqCode || `RFQ #${rfqId}`}
- </Badge>
- {rfqType && (
- <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1">
- {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"}
- </Badge>
- )}
- {rfq?.status && (
- <Badge
- variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
- className="ml-1"
- >
- {rfq.status}
- </Badge>
- )}
- </DialogTitle>
- <DialogDescription>
- {isEditable
- ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.')
- : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'}
- </DialogDescription>
- </DialogHeader>
- <div className="overflow-x-auto w-full">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <div className="space-y-4">
- {/* 헤더 행 (라벨) */}
- <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
- <div className="w-[250px] pl-3">아이템</div>
- <div className="w-[400px] pl-2">설명</div>
- <div className="w-[80px] pl-2 text-center">수량</div>
- <div className="w-[80px] pl-2 text-center">단위</div>
- {isEditable && <div className="w-[42px]"></div>}
- </div>
-
- {/* 아이템 행들 */}
- <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3">
- {fields.map((field, index) => {
- // 현재 row의 itemCode
- const codeValue = watchItems[index]?.itemCode || "";
- // "이미" 사용된 코드를 모두 구함
- const usedCodes = watchItems
- .map((it, i) => i === index ? null : it.itemCode)
- .filter(Boolean) as string[];
-
- // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고,
- // 다른 행에서 이미 사용한 code는 제거
- const filteredItems = (itemsList || [])
- .filter((it) => {
- if (!it.code) return false;
- if (it.code === codeValue) return true;
- return !usedCodes.includes(it.code);
- })
- .map((it) => ({
- code: it.code ?? "", // fallback
- name: it.name,
- }));
-
- // 선택된 아이템 찾기
- const selected = filteredItems.find(it => it.code === codeValue);
-
- return (
- <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors">
- {/* -- itemCode + Popover(Select) -- */}
- {isEditable ? (
- // 전체 FormField 컴포넌트와 아이템 선택 로직 개선
- <FormField
- control={form.control}
- name={`items.${index}.itemCode`}
- render={({ field }) => {
- const [popoverOpen, setPopoverOpen] = React.useState(false);
- const selected = filteredItems.find(it => it.code === field.value);
-
- return (
- <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}>
- <FormControl>
- <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
- <PopoverTrigger asChild>
- <Button
- // 컴포넌트에 ref 전달
- ref={el => {
- inputRefs.current[index] = el;
- }}
- variant="outline"
- role="combobox"
- aria-expanded={popoverOpen}
- className="flex items-center"
- data-error={!!form.formState.errors.items?.[index]?.itemCode}
- data-state={selected ? "filled" : "empty"}
- style={{width:250}}
- >
- <div className="flex-1 overflow-hidden mr-2 text-left">
- <span className="block truncate" style={{width:200}}>
- {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."}
- </span>
- </div>
- <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus />
- <CommandList>
- <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty>
- <CommandGroup>
- {filteredItems.map((it) => {
- const label = `${it.code} - ${it.name}`;
- return (
- <CommandItem
- key={it.code}
- value={label}
- onSelect={() => {
- field.onChange(it.code);
- setPopoverOpen(false);
- // 자동으로 다음 필드로 포커스 이동
- focusField(`input[name="items.${index}.description"]`);
- }}
- >
- <div className="flex-1 overflow-hidden">
- <span className="block truncate">{label}</span>
- </div>
- <Check
- className={
- "ml-auto h-4 w-4" +
- (it.code === field.value ? " opacity-100" : " opacity-0")
- }
- />
- </CommandItem>
- );
- })}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </FormControl>
- {form.formState.errors.items?.[index]?.itemCode && (
- <AlertCircle className="h-4 w-4 text-destructive" />
- )}
- </FormItem>
- );
- }}
- />
- ) : (
- <div className="flex items-center w-[250px] pl-3">
- {selected ? `${selected.code} - ${selected.name}` : codeValue}
- </div>
- )}
-
- {/* ID 필드 추가 (숨김) */}
- <FormField
- control={form.control}
- name={`items.${index}.id`}
- render={({ field }) => (
- <input type="hidden" {...field} />
- )}
- />
-
- {/* description */}
- {isEditable ? (
- <FormField
- control={form.control}
- name={`items.${index}.description`}
- render={({ field }) => (
- <FormItem className="w-[400px]">
- <FormControl>
- <Input
- className="w-full"
- placeholder="아이템 상세 정보"
- {...field}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- focusField(`input[name="items.${index}.quantity"]`);
- }
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- ) : (
- <div className="w-[400px] pl-2">
- {watchItems[index]?.description || ""}
- </div>
- )}
-
- {/* quantity */}
- {isEditable ? (
- <FormField
- control={form.control}
- name={`items.${index}.quantity`}
- render={({ field }) => (
- <FormItem className="w-[80px] relative">
- <FormControl>
- <Input
- type="number"
- className="w-full text-center"
- min="1"
- {...field}
- // 값 변경 핸들러 개선
- onChange={(e) => {
- const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10);
- field.onChange(isNaN(value) ? 1 : value);
- }}
- // 최소값 보장 (빈 문자열 방지)
- onBlur={(e) => {
- if (e.target.value === '' || parseInt(e.target.value, 10) < 1) {
- field.onChange(1);
- }
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- focusField(`input[name="items.${index}.uom"]`);
- }
- }}
- />
- </FormControl>
- {form.formState.errors.items?.[index]?.quantity && (
- <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" />
- )}
- </FormItem>
- )}
- />
- ) : (
- <div className="w-[80px] text-center">
- {watchItems[index]?.quantity}
- </div>
- )}
-
- {/* uom */}
- {isEditable ? (
- <FormField
- control={form.control}
- name={`items.${index}.uom`}
- render={({ field }) => (
- <FormItem className="w-[80px]">
- <FormControl>
- <Input
- placeholder="each"
- className="w-full text-center"
- {...field}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- // 마지막 행이면 새로운 행 추가
- if (index === fields.length - 1) {
- handleAddItem();
- } else {
- // 아니면 다음 행의 아이템 선택으로 이동
- const button = inputRefs.current[index + 1];
- if (button) {
- setTimeout(() => button.click(), 10);
- }
- }
- }
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- ) : (
- <div className="w-[80px] text-center">
- {watchItems[index]?.uom || "each"}
- </div>
- )}
-
- {/* remove row - 편집 모드에서만 표시 */}
- {isEditable && (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- type="button"
- variant="ghost"
- size="icon"
- onClick={() => handleRemoveItem(index)}
- className="group-hover:opacity-100 transition-opacity"
- aria-label="아이템 삭제"
- >
- <Trash2 className="h-4 w-4 text-destructive" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>아이템 삭제</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )}
- </div>
- );
- })}
- </div>
-
- <div className="flex justify-between items-center pt-2 border-t">
- <div className="flex items-center gap-2">
- {isEditable ? (
- <>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1">
- <Plus className="h-4 w-4" />
- 아이템 추가
- </Button>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- <p>단축키: Alt+N</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- <span className="text-sm text-muted-foreground">
- {fields.length}개 아이템
- </span>
- {deletedItemIds.length > 0 && (
- <span className="text-sm text-destructive">
- ({deletedItemIds.length}개 아이템 삭제 예정)
- </span>
- )}
- </>
- ) : (
- <span className="text-sm text-muted-foreground">
- {fields.length}개 아이템
- </span>
- )}
- </div>
-
- {isEditable && (
- <div className="text-xs text-muted-foreground">
- <span className="inline-flex items-center gap-1 mr-2">
- <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd>
- <span>필드 간 이동</span>
- </span>
- <span className="inline-flex items-center gap-1">
- <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd>
- <span>다음 필드로 이동</span>
- </span>
- </div>
- )}
- </div>
- </div>
-
- <DialogFooter className="mt-6 gap-2">
- {isEditable ? (
- <>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}>
- <X className="mr-2 h-4 w-4" />
- 취소
- </Button>
- </TooltipTrigger>
- <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent>
- </Tooltip>
- </TooltipProvider>
-
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- type="submit"
- disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid}
- >
- {isSubmitting ? (
- <>처리 중...</>
- ) : (
- <>
- <Save className="mr-2 h-4 w-4" />
- 저장
- </>
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>단축키: Ctrl+S</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </>
- ) : (
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- <X className="mr-2 h-4 w-4" />
- 닫기
- </Button>
- )}
- </DialogFooter>
- </form>
- </Form>
- </div>
- </DialogContent>
- </Dialog>
-
- {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */}
- {isEditable && (
- <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle>
- <AlertDialogDescription>
- 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel>취소</AlertDialogCancel>
- <AlertDialogAction onClick={() => {
- setIsExitDialogOpen(false);
- onOpenChange(false);
- }}>
- 저장하지 않고 나가기
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )}
- </>
- );
-} \ No newline at end of file
diff --git a/lib/rfqs/table/ParentRfqSelector.tsx b/lib/rfqs/table/ParentRfqSelector.tsx
deleted file mode 100644
index 0edb1233..00000000
--- a/lib/rfqs/table/ParentRfqSelector.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Check, ChevronsUpDown, Loader } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import { cn } from "@/lib/utils"
-import { useDebounce } from "@/hooks/use-debounce"
-import { getBudgetaryRfqs, type BudgetaryRfq } from "../service"
-import { RfqType } from "../validations"
-
-// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함)
-interface ParentRfq {
- id: number;
- rfqCode: string;
- description: string | null;
- rfqType: RfqType;
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
-}
-
-interface ParentRfqSelectorProps {
- selectedRfqId?: number;
- onRfqSelect: (rfq: ParentRfq | null) => void;
- rfqType: RfqType; // 현재 생성 중인 RFQ 타입
- parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록
- placeholder?: string;
-}
-
-export function ParentRfqSelector({
- selectedRfqId,
- onRfqSelect,
- rfqType,
- parentRfqTypes,
- placeholder = "부모 RFQ 선택..."
-}: ParentRfqSelectorProps) {
- const [searchTerm, setSearchTerm] = React.useState("");
- const debouncedSearchTerm = useDebounce(searchTerm, 300);
-
- const [open, setOpen] = React.useState(false);
- const [loading, setLoading] = React.useState(false);
- const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]);
- const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null);
- const [page, setPage] = React.useState(1);
- const [hasMore, setHasMore] = React.useState(true);
- const [totalCount, setTotalCount] = React.useState(0);
-
- const listRef = React.useRef<HTMLDivElement>(null);
-
- // 타입별로 적절한 검색 placeholder 생성
- const getSearchPlaceholder = () => {
- if (rfqType === RfqType.PURCHASE) {
- return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색...";
- } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
- return "BUDGETARY RFQ 검색...";
- }
- return "RFQ 코드/설명/프로젝트 검색...";
- };
-
- // 초기 선택된 RFQ가 있을 경우 로드
- React.useEffect(() => {
- if (selectedRfqId && open) {
- const loadSelectedRfq = async () => {
- try {
- // 단일 RFQ를 id로 조회하는 API 호출
- const result = await getBudgetaryRfqs({
- limit: 1,
- rfqId: selectedRfqId
- });
-
- if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) {
- setSelectedRfq(result.rfqs[0] as unknown as ParentRfq);
- }
- } catch (error) {
- console.error("선택된 RFQ 로드 오류:", error);
- }
- };
-
- if (!selectedRfq || selectedRfq.id !== selectedRfqId) {
- loadSelectedRfq();
- }
- }
- }, [selectedRfqId, open, selectedRfq]);
-
- // 검색어 변경 시 데이터 리셋 및 재로드
- React.useEffect(() => {
- if (open) {
- setPage(1);
- setHasMore(true);
- setParentRfqs([]);
- loadParentRfqs(1, true);
- }
- }, [debouncedSearchTerm, open, parentRfqTypes]);
-
- // 데이터 로드 함수
- const loadParentRfqs = async (pageToLoad: number, reset = false) => {
- if (!open || parentRfqTypes.length === 0) return;
-
- setLoading(true);
- try {
- const limit = 20; // 한 번에 로드할 항목 수
- const result = await getBudgetaryRfqs({
- search: debouncedSearchTerm,
- limit,
- offset: (pageToLoad - 1) * limit,
- rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링
- });
-
- if ('rfqs' in result && result.rfqs) {
- if (reset) {
- setParentRfqs(result.rfqs as unknown as ParentRfq[]);
- } else {
- setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]);
- }
-
- setTotalCount(result.totalCount);
- setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount);
- setPage(pageToLoad);
- }
- } catch (error) {
- console.error("부모 RFQ 로드 오류:", error);
- } finally {
- setLoading(false);
- }
- };
-
- // 무한 스크롤 처리
- const handleScroll = () => {
- if (listRef.current) {
- const { scrollTop, scrollHeight, clientHeight } = listRef.current;
-
- // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드
- if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) {
- loadParentRfqs(page + 1);
- }
- }
- };
-
- // RFQ를 프로젝트별로 그룹화하는 함수
- const groupRfqsByProject = (rfqs: ParentRfq[]) => {
- const groups: Record<string, {
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
- rfqs: ParentRfq[];
- }> = {};
-
- // 'No Project' 그룹 기본 생성
- groups['no-project'] = {
- projectId: null,
- projectCode: null,
- projectName: null,
- rfqs: []
- };
-
- // 프로젝트별로 RFQ 그룹화
- rfqs.forEach(rfq => {
- const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project';
-
- if (!groups[key] && rfq.projectId) {
- groups[key] = {
- projectId: rfq.projectId,
- projectCode: rfq.projectCode,
- projectName: rfq.projectName,
- rfqs: []
- };
- }
-
- groups[key].rfqs.push(rfq);
- });
-
- // 필터링된 결과가 있는 그룹만 남기기
- return Object.values(groups).filter(group => group.rfqs.length > 0);
- };
-
- // 그룹화된 RFQ 목록
- const groupedRfqs = React.useMemo(() => {
- return groupRfqsByProject(parentRfqs);
- }, [parentRfqs]);
-
- // RFQ 선택 처리
- const handleRfqSelect = (rfq: ParentRfq | null) => {
- setSelectedRfq(rfq);
- onRfqSelect(rfq);
- setOpen(false);
- };
-
- // RFQ 타입에 따른 표시 형식
- const getRfqTypeLabel = (type: RfqType) => {
- switch(type) {
- case RfqType.BUDGETARY:
- return "BUDGETARY";
- case RfqType.PURCHASE_BUDGETARY:
- return "PURCHASE_BUDGETARY";
- case RfqType.PURCHASE:
- return "PURCHASE";
- default:
- return type;
- }
- };
-
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={open}
- className="w-full justify-between"
- >
- {selectedRfq
- ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}`
- : placeholder}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput
- placeholder={getSearchPlaceholder()}
- value={searchTerm}
- onValueChange={setSearchTerm}
- />
- <CommandList
- className="max-h-[300px]"
- ref={listRef}
- onScroll={handleScroll}
- >
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
-
- <CommandGroup>
- <CommandItem
- value="none"
- onSelect={() => handleRfqSelect(null)}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- !selectedRfq
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- <span className="font-medium">선택 안함</span>
- </CommandItem>
- </CommandGroup>
-
- {groupedRfqs.map((group, index) => (
- <CommandGroup
- key={`group-${group.projectId || index}`}
- heading={
- group.projectId
- ? `${group.projectCode || ""} - ${group.projectName || ""}`
- : "프로젝트 없음"
- }
- >
- {group.rfqs.map((rfq) => (
- <CommandItem
- key={rfq.id}
- value={`${rfq.rfqCode || ""} ${rfq.description || ""}`}
- onSelect={() => handleRfqSelect(rfq)}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- selectedRfq?.id === rfq.id
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- <div className="flex flex-col">
- <div className="flex items-center">
- <span className="font-medium">{rfq.rfqCode || ""}</span>
- <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700">
- {getRfqTypeLabel(rfq.rfqType)}
- </span>
- </div>
- {rfq.description && (
- <span className="text-sm text-gray-500 truncate">
- {rfq.description}
- </span>
- )}
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- ))}
-
- {loading && (
- <div className="py-2 text-center">
- <Loader className="h-4 w-4 animate-spin mx-auto" />
- </div>
- )}
-
- {!loading && !hasMore && parentRfqs.length > 0 && (
- <div className="py-2 text-center text-sm text-muted-foreground">
- 총 {totalCount}개 중 {parentRfqs.length}개 표시됨
- </div>
- )}
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-} \ No newline at end of file
diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
deleted file mode 100644
index 67561b4f..00000000
--- a/lib/rfqs/table/add-rfq-dialog.tsx
+++ /dev/null
@@ -1,468 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { toast } from "sonner"
-
-import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-
-import { useSession } from "next-auth/react"
-import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations"
-import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import { type Project } from "../service"
-import { ParentRfqSelector } from "./ParentRfqSelector"
-import { EstimateProjectSelector } from "@/components/BidProjectSelector"
-
-// 부모 RFQ 정보 타입 정의
-interface ParentRfq {
- id: number;
- rfqCode: string;
- description: string | null;
- rfqType: RfqType;
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
-}
-
-interface AddRfqDialogProps {
- rfqType?: RfqType;
-}
-
-export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const { data: session, status } = useSession()
- const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([])
- const [isLoadingParents, setIsLoadingParents] = React.useState(false)
- const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null)
- const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false)
-
- // Get the user ID safely, ensuring it's a valid number
- const userId = React.useMemo(() => {
- const id = session?.user?.id ? Number(session.user.id) : null;
-
- return id;
- }, [session, status]);
-
- // RfqType에 따른 타이틀 생성
- const getTitle = () => {
- switch (rfqType) {
- case RfqType.PURCHASE:
- return "Purchase RFQ";
- case RfqType.BUDGETARY:
- return "Budgetary RFQ";
- case RfqType.PURCHASE_BUDGETARY:
- return "Purchase Budgetary RFQ";
- default:
- return "RFQ";
- }
- };
-
- // RfqType 설명 가져오기
- const getTypeDescription = () => {
- switch (rfqType) {
- case RfqType.PURCHASE:
- return "실제 구매 발주 전에 가격을 요청";
- case RfqType.BUDGETARY:
- return "기술영업 단계에서 입찰가 산정을 위한 견적 요청";
- case RfqType.PURCHASE_BUDGETARY:
- return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청";
- default:
- return "";
- }
- };
-
- // RHF + Zod
- const form = useForm<CreateRfqSchema>({
- resolver: zodResolver(createRfqSchema),
- defaultValues: {
- rfqCode: "",
- description: "",
- projectId: undefined,
- parentRfqId: undefined,
- dueDate: new Date(),
- status: "DRAFT",
- rfqType: rfqType,
- // Don't set createdBy yet - we'll set it when the form is submitted
- createdBy: undefined,
- },
- });
-
- // Update form values when session loads
- React.useEffect(() => {
- if (status === "authenticated" && userId) {
- form.setValue("createdBy", userId);
- }
- }, [status, userId, form]);
-
- // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성
- React.useEffect(() => {
- if (open) {
- const generateRfqCode = async () => {
- setIsLoadingRfqCode(true);
- try {
- // 서버 액션 호출
- const result = await generateNextRfqCode(rfqType);
-
- if (result.error) {
- toast.error(`RFQ 코드 생성 실패: ${result.error}`);
- return;
- }
-
- // 생성된 코드를 폼에 설정
- form.setValue("rfqCode", result.code);
- } catch (error) {
- console.error("RFQ 코드 생성 오류:", error);
- toast.error("RFQ 코드 생성에 실패했습니다");
- } finally {
- setIsLoadingRfqCode(false);
- }
- };
-
- generateRfqCode();
- }
- }, [open, rfqType, form]);
-
- // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정
- const getParentRfqTypes = (): RfqType[] => {
- switch (rfqType) {
- case RfqType.PURCHASE:
- // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음
- return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY];
- case RfqType.PURCHASE_BUDGETARY:
- // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음
- return [RfqType.BUDGETARY];
- default:
- return [];
- }
- };
-
- // 선택 가능한 부모 RFQ 목록 로드
- React.useEffect(() => {
- if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) {
- const loadParentRfqs = async () => {
- setIsLoadingParents(true);
- try {
- // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기
- const parentTypes = getParentRfqTypes();
-
- // 부모 RFQ 타입이 있을 때만 API 호출
- if (parentTypes.length > 0) {
- const result = await getBudgetaryRfqs({
- rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요
- });
-
- if ('rfqs' in result) {
- setParentRfqs(result.rfqs as unknown as ParentRfq[]);
- } else if ('error' in result) {
- console.error("부모 RFQ 로드 오류:", result.error);
- }
- }
- } catch (error) {
- console.error("부모 RFQ 로드 오류:", error);
- } finally {
- setIsLoadingParents(false);
- }
- };
-
- loadParentRfqs();
- }
- }, [rfqType, open]);
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project | null) => {
- if (project === null) {
- return;
- }
-
- form.setValue("projectId", project.id);
- };
-
- const handleBidProjectSelect = (project: Project | null) => {
- if (project === null) {
- return;
- }
-
- form.setValue("bidProjectId", project.id);
- };
-
- // 부모 RFQ 선택 처리
- const handleParentRfqSelect = (rfq: ParentRfq | null) => {
- setSelectedParentRfq(rfq);
- form.setValue("parentRfqId", rfq?.id);
- };
-
- async function onSubmit(data: CreateRfqSchema) {
- // Check if user is authenticated before submitting
- if (status !== "authenticated" || !userId) {
- toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요.");
- return;
- }
-
- // Make sure createdBy is set with the current user ID
- const submitData = {
- ...data,
- createdBy: userId
- };
-
- console.log("Submitting form data:", submitData);
-
- const result = await createRfq(submitData);
- if (result.error) {
- toast.error(`에러: ${result.error}`);
- return;
- }
-
- toast.success("RFQ가 성공적으로 생성되었습니다.");
- form.reset();
- setSelectedParentRfq(null);
- setOpen(false);
- }
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- form.reset();
- setSelectedParentRfq(null);
- }
- setOpen(nextOpen);
- }
-
- // Return a message or disabled state if user is not authenticated
- if (status === "loading") {
- return <Button variant="outline" size="sm" disabled>Loading...</Button>;
- }
-
- // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정
- const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY;
- const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY;
-
- // 부모 RFQ 선택기 레이블 및 설명 가져오기
- const getParentRfqSelectorLabel = () => {
- if (rfqType === RfqType.PURCHASE) {
- return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)";
- } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
- return "부모 RFQ (BUDGETARY)";
- }
- return "부모 RFQ";
- };
-
- const getParentRfqDescription = () => {
- if (rfqType === RfqType.PURCHASE) {
- return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다.";
- } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
- return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다.";
- }
- return "";
- };
-
- return (
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- {/* 모달을 열기 위한 버튼 */}
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- Add {getTitle()}
- </Button>
- </DialogTrigger>
-
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Create New {getTitle()}</DialogTitle>
- <DialogDescription>
- 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
- <div className="mt-1 text-xs text-muted-foreground">
- {getTypeDescription()}
- </div>
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <div className="space-y-4 py-4">
- {/* rfqType - hidden field */}
- <FormField
- control={form.control}
- name="rfqType"
- render={({ field }) => (
- <input type="hidden" {...field} />
- )}
- />
-
- {/* Project Selector */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Project</FormLabel>
- <FormControl>
-
- {shouldShowEstimateSelector ?
- <EstimateProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleBidProjectSelect}
- placeholder="견적 프로젝트 선택..."
- /> :
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />}
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */}
- {shouldShowParentRfqSelector && (
- <FormField
- control={form.control}
- name="parentRfqId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{getParentRfqSelectorLabel()}</FormLabel>
- <FormControl>
- <ParentRfqSelector
- selectedRfqId={field.value as number | undefined}
- onRfqSelect={handleParentRfqSelect}
- rfqType={rfqType}
- parentRfqTypes={getParentRfqTypes()}
- placeholder={
- rfqType === RfqType.PURCHASE
- ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
- : "BUDGETARY RFQ 선택..."
- }
- />
- </FormControl>
- <div className="text-xs text-muted-foreground mt-1">
- {getParentRfqDescription()}
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* rfqCode - 자동 생성되고 읽기 전용 */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Code</FormLabel>
- <FormControl>
- <div className="flex">
- <Input
- placeholder="자동으로 생성 중..."
- {...field}
- disabled={true}
- className="bg-muted"
- />
- {isLoadingRfqCode && (
- <div className="ml-2 flex items-center">
- <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
- </div>
- )}
- </div>
- </FormControl>
- <div className="text-xs text-muted-foreground mt-1">
- RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* description */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Description</FormLabel>
- <FormControl>
- <Input placeholder="e.g. 설명을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* dueDate */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Due Date</FormLabel>
- <FormControl>
- <Input
- type="date"
- value={field.value ? field.value.toISOString().slice(0, 10) : ""}
- onChange={(e) => {
- const val = e.target.value
- if (val) {
- const date = new Date(val);
- // 날짜 1일씩 밀리는 문제로 우선 KTC로 입력
- // 추후 아래와 같이 수정
- // 1. 해당 유저 타임존 값으로 입력
- // 2. DB에는 UTC 타임존 값으로 저장
- // 3. 출력시 유저별 타임존 값으로 변환해 출력
- // 4. 어떤 타임존으로 나오는지도 함께 렌더링
- // field.onChange(new Date(val + "T00:00:00"))
- field.onChange(date);
- }
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* status (Read-only) */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <FormControl>
- <Input
- disabled
- className="capitalize"
- {...field}
- onChange={() => { }} // Prevent changes
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={form.formState.isSubmitting || status !== "authenticated"}
- >
- Create
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx
deleted file mode 100644
index fdfb5e9a..00000000
--- a/lib/rfqs/table/attachment-rfq-sheet.tsx
+++ /dev/null
@@ -1,429 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { useToast } from "@/hooks/use-toast"
-import { Badge } from "@/components/ui/badge"
-
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
-} from "@/components/ui/file-list"
-
-import prettyBytes from "pretty-bytes"
-import { processRfqAttachments } from "../service"
-import { formatDate } from "@/lib/utils"
-import { RfqType } from "../validations"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-import { quickDownload } from "@/lib/file-download"
-import { type FileRejection } from "react-dropzone"
-
-const MAX_FILE_SIZE = 6e8 // 600MB
-
-/** 기존 첨부 파일 정보 */
-interface ExistingAttachment {
- id: number
- fileName: string
- filePath: string
- createdAt?: Date // or Date
- vendorId?: number | null
- size?: number
-}
-
-/** 새로 업로드할 파일 */
-const newUploadSchema = z.object({
- fileObj: z.any().optional(), // 실제 File
-})
-
-/** 기존 첨부 (react-hook-form에서 관리) */
-const existingAttachSchema = z.object({
- id: z.number(),
- fileName: z.string(),
- filePath: z.string(),
- vendorId: z.number().nullable().optional(),
- createdAt: z.custom<Date>().optional(), // or use z.any().optional()
- size: z.number().optional(),
-})
-
-/** RHF 폼 전체 스키마 */
-const attachmentsFormSchema = z.object({
- rfqId: z.number().int(),
- existing: z.array(existingAttachSchema),
- newUploads: z.array(newUploadSchema),
-})
-
-type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
-
-interface RfqAttachmentsSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- defaultAttachments?: ExistingAttachment[]
- rfqType?: RfqType
- rfq: RfqWithItemCount | null
- /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */
- onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void
-}
-
-/**
- * RfqAttachmentsSheet:
- * - 기존 첨부 목록 (다운로드 + 삭제)
- * - 새 파일 Dropzone
- * - Save 시 processRfqAttachments(server action)
- */
-export function RfqAttachmentsSheet({
- defaultAttachments = [],
- onAttachmentsUpdated,
- rfq,
- rfqType,
- ...props
-}: RfqAttachmentsSheetProps) {
- const { toast } = useToast()
- const [isPending, startUpdate] = React.useTransition()
- const rfqId = rfq?.rfqId ?? 0;
-
- // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
- const isEditable = rfq?.status === "DRAFT";
-
- // React Hook Form
- const form = useForm<AttachmentsFormValues>({
- resolver: zodResolver(attachmentsFormSchema),
- defaultValues: {
- rfqId,
- existing: [],
- newUploads: [],
- },
- })
-
- const { reset, control, handleSubmit } = form
-
- // defaultAttachments가 바뀔 때마다, RHF 상태를 reset
- React.useEffect(() => {
- reset({
- rfqId,
- existing: defaultAttachments.map((att) => ({
- ...att,
- vendorId: att.vendorId ?? null,
- size: att.size ?? undefined,
- })),
- newUploads: [],
- })
- }, [rfqId, defaultAttachments, reset])
-
- // Field Arrays
- const {
- fields: existingFields,
- remove: removeExisting,
- } = useFieldArray({ control, name: "existing" })
-
- const {
- fields: newUploadFields,
- append: appendNewUpload,
- remove: removeNewUpload,
- } = useFieldArray({ control, name: "newUploads" })
-
- // 기존 첨부 항목 중 삭제된 것 찾기
- function findRemovedExistingIds(data: AttachmentsFormValues): number[] {
- const finalIds = data.existing.map((att) => att.id)
- const originalIds = defaultAttachments.map((att) => att.id)
- return originalIds.filter((id) => !finalIds.includes(id))
- }
-
- async function onSubmit(data: AttachmentsFormValues) {
- // 편집 불가능한 상태에서는 제출 방지
- if (!isEditable) return;
-
- startUpdate(async () => {
- try {
- const removedExistingIds = findRemovedExistingIds(data)
- const newFiles = data.newUploads
- .map((it) => it.fileObj)
- .filter((f): f is File => !!f)
-
- // 서버 액션
- const res = await processRfqAttachments({
- rfqId,
- removedExistingIds,
- newFiles,
- vendorId: null, // vendor ID if needed
- rfqType
- })
-
- if (!res.ok) throw new Error(res.error ?? "Unknown error")
-
- const newCount = res.updatedItemCount ?? 0
-
- toast({
- variant: "default",
- title: "Success",
- description: "File(s) updated",
- })
-
- // 상위 테이블 등에 itemCount 업데이트
- onAttachmentsUpdated?.(rfqId, newCount)
-
- // 모달 닫기
- props.onOpenChange?.(false)
- } catch (err) {
- toast({
- variant: "destructive",
- title: "Error",
- description: String(err),
- })
- }
- })
- }
-
- /** 기존 첨부 - X 버튼 */
- function handleRemoveExisting(idx: number) {
- // 편집 불가능한 상태에서는 삭제 방지
- if (!isEditable) return;
- removeExisting(idx)
- }
-
- /** 드롭존에서 파일 받기 */
- function handleDropAccepted(acceptedFiles: File[]) {
- // 편집 불가능한 상태에서는 파일 추가 방지
- if (!isEditable) return;
- const mapped = acceptedFiles.map((file) => ({ fileObj: file }))
- appendNewUpload(mapped)
- }
-
- /** 드롭존에서 파일 거부(에러) */
- function handleDropRejected(fileRejections: FileRejection[]) {
- // 편집 불가능한 상태에서는 무시
- if (!isEditable) return;
-
- fileRejections.forEach((rej) => {
- toast({
- variant: "destructive",
- title: "File Error",
- description: rej.file.name + " not accepted",
- })
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
- <SheetHeader>
- <SheetTitle className="flex items-center gap-2">
- {isEditable ? "Manage Attachments" : "View Attachments"}
- {rfq?.status && (
- <Badge
- variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
- className="ml-1"
- >
- {rfq.status}
- </Badge>
- )}
- </SheetTitle>
- <SheetDescription>
- {`RFQ ${rfq?.rfqCode} - `}
- {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'}
- {!isEditable && (
- <div className="mt-1 text-xs flex items-center gap-1 text-amber-600">
- <AlertCircle className="h-3 w-3" />
- <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span>
- </div>
- )}
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
- {/* 1) 기존 첨부 목록 */}
- <div className="space-y-2">
- <p className="font-semibold text-sm">Existing Attachments</p>
- {existingFields.length === 0 && (
- <p className="text-sm text-muted-foreground">No existing attachments</p>
- )}
- {existingFields.map((field, index) => {
- const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)"
- return (
- <div
- key={field.id}
- className="flex items-center justify-between rounded border p-2"
- >
- <div className="flex flex-col text-sm">
- <span className="font-medium">
- {field.fileName} {vendorLabel}
- </span>
- {field.size && (
- <span className="text-xs text-muted-foreground">
- {Math.round(field.size / 1024)} KB
- </span>
- )}
- {field.createdAt && (
- <span className="text-xs text-muted-foreground">
- Created at {formatDate(field.createdAt, "KR")}
- </span>
- )}
- </div>
- <div className="flex items-center gap-2">
- {/* 1) Download button (if filePath) */}
- {field.filePath && (
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => quickDownload(field.filePath, field.fileName)}
- >
- <Download className="h-4 w-4" />
- </Button>
- )}
- {/* 2) Remove button - 편집 가능할 때만 표시 */}
- {isEditable && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- onClick={() => handleRemoveExisting(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- )
- })}
- </div>
-
- {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {isEditable ? (
- <>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- >
- {({ maxSize }) => (
- <FormField
- control={control}
- name="newUploads" // not actually used for storing each file detail
- render={() => (
- <FormItem>
- <FormLabel>Drop Files Here</FormLabel>
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to upload</DropzoneTitle>
- <DropzoneDescription>
- Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- <FormDescription>Alternatively, click browse.</FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </Dropzone>
-
- {/* newUpload fields -> FileList */}
- {newUploadFields.length > 0 && (
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- {`Files (${newUploadFields.length})`}
- </h6>
- <FileList>
- {newUploadFields.map((field, idx) => {
- const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
- if (!fileObj) return null
-
- const fileName = fileObj.name
- const fileSize = fileObj.size
- return (
- <FileListItem key={field.id}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileName}</FileListName>
- <FileListDescription>
- {`${prettyBytes(fileSize)}`}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeNewUpload(idx)}>
- <X />
- <span className="sr-only">Remove</span>
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
- </>
- ) : (
- <div className="p-3 bg-muted rounded-md flex items-center justify-center">
- <div className="text-center text-sm text-muted-foreground">
- <Eye className="h-4 w-4 mx-auto mb-2" />
- <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
- </div>
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- {isEditable ? "Cancel" : "Close"}
- </Button>
- </SheetClose>
- {isEditable && (
- <Button
- type="submit"
- disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)}
- >
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- )}
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx
deleted file mode 100644
index 09596bc7..00000000
--- a/lib/rfqs/table/delete-rfqs-dialog.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
-import { removeRfqs } from "../service"
-
-interface DeleteRfqsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqs: Row<RfqWithItemCount>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteRfqsDialog({
- rfqs,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteRfqsDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startDeleteTransition(async () => {
- const { error } = await removeRfqs({
- ids: rfqs.map((rfq) => rfq.rfqId),
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("Tasks deleted")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- Delete ({rfqs.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Are you absolutely sure?</DialogTitle>
- <DialogDescription>
- This action cannot be undone. This will permanently delete your{" "}
- <span className="font-medium">{rfqs.length}</span>
- {rfqs.length === 1 ? " task" : " rfqs"} from our servers.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">Cancel</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- Delete
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- Delete ({rfqs.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>Are you absolutely sure?</DrawerTitle>
- <DrawerDescription>
- This action cannot be undone. This will permanently delete your{" "}
- <span className="font-medium">{rfqs.length}</span>
- {rfqs.length === 1 ? " task" : " rfqs"} from our servers.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- Delete
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-}
diff --git a/lib/rfqs/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/rfqs/table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/rfqs/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx
deleted file mode 100644
index aaae6af2..00000000
--- a/lib/rfqs/table/feature-flags.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface TasksTableContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const TasksTableContext = React.createContext<TasksTableContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useTasksTable() {
- const context = React.useContext(TasksTableContext)
- if (!context) {
- throw new Error("useTasksTable must be used within a TasksTableProvider")
- }
- return context
-}
-
-export function TasksTableProvider({ children }: React.PropsWithChildren) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "featureFlags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- }
- )
-
- return (
- <TasksTableContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit"
- >
- {dataTableConfig.featureFlags.map((flag) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className="whitespace-nowrap px-3 text-xs"
- asChild
- >
- <TooltipTrigger>
- <flag.icon
- className="mr-2 size-3.5 shrink-0"
- aria-hidden="true"
- />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </TasksTableContext.Provider>
- )
-}
diff --git a/lib/rfqs/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx
deleted file mode 100644
index 5c09fcf0..00000000
--- a/lib/rfqs/table/rfqs-table-columns.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Paperclip, Package } from "lucide-react"
-import { toast } from "sonner"
-
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
-import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-import { RfqType } from "../validations"
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null>
- >
- openItemsModal: (rfqId: number) => void
- openAttachmentsSheet: (rfqId: number) => void
- router: NextRouter
- rfqType?: RfqType
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- openItemsModal,
- openAttachmentsSheet,
- router,
- rfqType,
-}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<RfqWithItemCount> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<RfqWithItemCount> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- // Proceed 버튼 클릭 시 호출되는 함수
- const handleProceed = () => {
- const rfq = row.original
- const itemCount = Number(rfq.itemCount || 0)
- const attachCount = Number(rfq.attachCount || 0)
-
- // 아이템과 첨부파일이 모두 0보다 커야 진행 가능
- if (itemCount > 0 && attachCount > 0) {
- router.push(
- rfqType === RfqType.PURCHASE
- ? `/evcp/rfq/${rfq.rfqId}`
- : `/evcp/budgetary/${rfq.rfqId}`
- )
- } else {
- // 조건을 충족하지 않는 경우 토스트 알림 표시
- if (itemCount === 0 && attachCount === 0) {
- toast.error("아이템과 첨부파일을 먼저 추가해주세요.")
- } else if (itemCount === 0) {
- toast.error("아이템을 먼저 추가해주세요.")
- } else {
- toast.error("첨부파일을 먼저 추가해주세요.")
- }
- }
- }
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onSelect={handleProceed}>
- {row.original.status ==="DRAFT"?"Proceed":"View Detail"}
- <DropdownMenuShortcut>↵</DropdownMenuShortcut>
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge)
- // ----------------------------------------------------------------
- const itemsColumn: ColumnDef<RfqWithItemCount> = {
- id: "items",
- header: "Items",
- cell: ({ row }) => {
- const rfq = row.original
- const itemCount = rfq.itemCount || 0
-
- const handleClick = () => {
- openItemsModal(rfq.rfqId)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- itemCount > 0 ? `View ${itemCount} items` : "Add items"
- }
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {itemCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {itemCount}
- </Badge>
- )}
- <span className="sr-only">
- {itemCount > 0 ? `${itemCount} Items` : "Add Items"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- size: 60,
- }
-
- // ----------------------------------------------------------------
- // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge)
- // ----------------------------------------------------------------
- const attachmentsColumn: ColumnDef<RfqWithItemCount> = {
- id: "attachments",
- header: "Attachments",
- cell: ({ row }) => {
- const fileCount = row.original.attachCount ?? 0
-
- const handleClick = () => {
- openAttachmentsSheet(row.original.rfqId)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- fileCount > 0 ? `View ${fileCount} files` : "Add files"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {fileCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {fileCount}
- </Badge>
- )}
- <span className="sr-only">
- {fileCount > 0 ? `${fileCount} Files` : "Add Files"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- size: 60,
- }
-
- // ----------------------------------------------------------------
- // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {}
-
- rfqsColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<RfqWithItemCount> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
- if (cfg.id === "status") {
- const statusVal = row.original.status
- if (!statusVal) return null
- const Icon = getRFQStatusIcon(
- statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"
- )
- return (
- <div className="flex w-[6.25rem] items-center">
- <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
- <span className="capitalize">{statusVal}</span>
- </div>
- )
- }
-
- if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal, "KR")
- }
-
- return row.getValue(cfg.id) ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap -> nestedColumns
- const nestedColumns: ColumnDef<RfqWithItemCount>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- attachmentsColumn, // 첨부파일
- actionsColumn,
- itemsColumn, // 아이템
- ]
-} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx
deleted file mode 100644
index daef7e0b..00000000
--- a/lib/rfqs/table/rfqs-table-floating-bar.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Table } from "@tanstack/react-table"
-import { toast } from "sonner"
-import { Calendar, type CalendarProps } from "@/components/ui/calendar"
-import { Button } from "@/components/ui/button"
-import { Portal } from "@/components/ui/portal"
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectValue,
-} from "@/components/ui/select"
-import { Separator } from "@/components/ui/separator"
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip"
-import { Kbd } from "@/components/kbd"
-import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-
-import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react"
-
-import { exportTableToExcel } from "@/lib/export"
-
-import { RfqWithItemCount, rfqs } from "@/db/schema/rfq"
-import { modifyRfqs, removeRfqs } from "../service"
-
-interface RfqsTableFloatingBarProps {
- table: Table<RfqWithItemCount>
-}
-
-/**
- * 추가된 로직:
- * - 달력(캘린더) 아이콘 버튼
- * - 눌렀을 때 Popover로 Calendar 표시
- * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate })
- */
-export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) {
- const rows = table.getFilteredSelectedRowModel().rows
- const [isPending, startTransition] = React.useTransition()
- const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">()
- const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
-
- const [confirmProps, setConfirmProps] = React.useState<{
- title: string
- description?: string
- onConfirm: () => Promise<void> | void
- }>({
- title: "",
- description: "",
- onConfirm: () => {},
- })
-
- // 캘린더 Popover 열림 여부
- const [calendarOpen, setCalendarOpen] = React.useState(false)
- const [selectedDate, setSelectedDate] = React.useState<Date | null>(null)
-
- // Clear selection on Escape key press
- React.useEffect(() => {
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- table.toggleAllRowsSelected(false)
- }
- }
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [table])
-
- function handleDeleteConfirm() {
- setAction("delete")
- setConfirmProps({
- title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`,
- description: "This action cannot be undone.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await removeRfqs({
- ids: rows.map((row) => row.original.rfqId),
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("RFQs deleted")
- table.toggleAllRowsSelected(false)
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- function handleSelectStatus(newStatus: RfqWithItemCount["status"]) {
- setAction("update-status")
- setConfirmProps({
- title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
- description: "This action will override their current status.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyRfqs({
- ids: rows.map((row) => row.original.rfqId),
- status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("RFQs updated")
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그
- function handleDueDateSelect(newDate: Date) {
- setAction("update-dueDate")
-
- setConfirmProps({
- title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`,
- description: "This action will override their current due date.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyRfqs({
- ids: rows.map((r) => r.original.rfqId),
- dueDate: newDate,
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("Due date updated")
- setConfirmDialogOpen(false)
- setCalendarOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- // 2) Export
- function handleExport() {
- setAction("export")
- startTransition(() => {
- exportTableToExcel(table, {
- excludeColumns: ["select", "actions"],
- onlySelected: true,
- })
- })
- }
-
- // Floating bar UI
- return (
- <Portal>
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5">
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- {/* Selection Info + Clear */}
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
-
- <Separator orientation="vertical" className="hidden h-5 sm:block" />
-
- <div className="flex items-center gap-1.5">
- {/* 1) Status Update */}
- <Select
- onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)}
- >
- <Tooltip>
- <SelectTrigger asChild>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
- disabled={isPending}
- >
- {isPending && action === "update-status" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <CheckCircle2 className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- </SelectTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update status</p>
- </TooltipContent>
- </Tooltip>
- <SelectContent align="center">
- <SelectGroup>
- {rfqs.status.enumValues.map((status) => (
- <SelectItem key={status} value={status} className="capitalize">
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
-
- {/* 2) Due Date Update: Calendar Popover */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- disabled={isPending}
- onClick={() => setCalendarOpen((open) => !open)}
- >
- {isPending && action === "update-dueDate" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <CalendarIcon className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update Due Date</p>
- </TooltipContent>
- </Tooltip>
-
- {/* Calendar Popover (간단 구현) */}
- {calendarOpen && (
- <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow">
- <Calendar
- mode="single"
- selected={selectedDate || new Date()}
- onSelect={(date) => {
- if (date) {
- setSelectedDate(date)
- handleDueDateSelect(date)
- }
- }}
- initialFocus
- />
- </div>
- )}
-
- {/* 3) Export */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={handleExport}
- disabled={isPending}
- >
- {isPending && action === "export" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <Download className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Export tasks</p>
- </TooltipContent>
- </Tooltip>
-
- {/* 4) Delete */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={handleDeleteConfirm}
- disabled={isPending}
- >
- {isPending && action === "delete" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <Trash2 className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Delete tasks</p>
- </TooltipContent>
- </Tooltip>
- </div>
- </div>
- </div>
- </div>
-
- {/* 공용 Confirm Dialog */}
- <ActionConfirmDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- title={confirmProps.title}
- description={confirmProps.description}
- onConfirm={confirmProps.onConfirm}
- isLoading={
- isPending && (action === "delete" || action === "update-status" || action === "update-dueDate")
- }
- confirmLabel={
- action === "delete"
- ? "Delete"
- : action === "update-status"
- ? "Update"
- : action === "update-dueDate"
- ? "Update"
- : "Confirm"
- }
- confirmVariant={action === "delete" ? "destructive" : "default"}
- />
- </Portal>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
deleted file mode 100644
index 6402e625..00000000
--- a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
-import { AddRfqDialog } from "./add-rfq-dialog"
-import { RfqType } from "../validations"
-
-
-interface RfqsTableToolbarActionsProps {
- table: Table<RfqWithItemCount>
- rfqType?: RfqType;
-}
-
-export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) {
- return (
- <div className="flex items-center gap-2">
- {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <DeleteRfqsDialog
- rfqs={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- ) : null}
-
- {/** 2) 새 Task 추가 다이얼로그 */}
- <AddRfqDialog rfqType={rfqType} />
-
-
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx
deleted file mode 100644
index 287f1d53..00000000
--- a/lib/rfqs/table/rfqs-table.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./rfqs-table-columns"
-import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service"
-import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq"
-import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar"
-import { UpdateRfqSheet } from "./update-rfq-sheet"
-import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
-import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions"
-import { RfqsItemsDialog } from "./ItemsDialog"
-import { getAllItems } from "@/lib/items/service"
-import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
-import { useRouter } from "next/navigation"
-import { RfqType } from "../validations"
-
-interface RfqsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getRfqs>>,
- Awaited<ReturnType<typeof getRfqStatusCounts>>,
- Awaited<ReturnType<typeof getAllItems>>,
- ]
- >;
- rfqType?: RfqType; // rfqType props 추가
-}
-
-export interface ExistingAttachment {
- id: number;
- fileName: string;
- filePath: string;
- createdAt?: Date;
- vendorId?: number | null;
- size?: number;
-}
-
-export interface ExistingItem {
- id?: number;
- itemCode: string;
- description: string | null;
- quantity: number | null;
- uom: string | null;
-}
-
-export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
- const [{ data, pageCount }, statusCounts, items] = React.use(promises)
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
- const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
- const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([])
-
- const router = useRouter()
-
- const itemsList = items?.map((v) => ({
- code: v.itemCode ?? "",
- name: v.itemName ?? "",
- }));
-
- const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<RfqWithItemCount> | null>(null)
-
- const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data)
-
- const [itemsModalOpen, setItemsModalOpen] = React.useState(false);
- const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null);
-
-
- const selectedRfq = React.useMemo(() => {
- return rowData.find(row => row.rfqId === selectedRfqId) || null;
- }, [rowData, selectedRfqId]);
-
- // rfqType에 따른 제목 계산
- const getRfqTypeTitle = () => {
- return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ";
- };
-
- async function openItemsModal(rfqId: number) {
- const itemList = await fetchRfqItems(rfqId)
- setItemsDefault(itemList)
- setSelectedRfqId(rfqId);
- setItemsModalOpen(true);
- }
-
- async function openAttachmentsSheet(rfqId: number) {
- // 4.1) Fetch current attachments from server (server action)
- const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[]
- setAttachDefault(list)
- setSelectedRfqIdForAttachments(rfqId)
- setAttachmentsOpen(true)
- setSelectedRfqId(rfqId);
- }
-
- function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) {
- // 5.1) update rowData itemCount
- setRowData(prev =>
- prev.map(r =>
- r.rfqId === rfqId
- ? { ...r, itemCount: newCount }
- : r
- )
- )
- // 5.2) if newList is provided, store it
- if (newList) {
- setAttachDefault(newList)
- }
- }
-
- const columns = React.useMemo(() => getColumns({
- setRowAction, router,
- // we pass openItemsModal as a prop so the itemsColumn can call it
- openItemsModal,
- openAttachmentsSheet,
- rfqType
- }), [setRowAction, router, rfqType]);
-
- /**
- * This component can render either a faceted filter or a search filter based on the `options` prop.
- */
- const filterFields: DataTableFilterField<RfqWithItemCount>[] = [
- {
- id: "rfqCode",
- label: "RFQ Code",
- placeholder: "Filter RFQ Code...",
- },
- {
- id: "status",
- label: "Status",
- options: rfqs.status.enumValues?.map((status) => {
- // 명시적으로 status를 허용된 리터럴 타입으로 변환
- const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
- return {
- label: toSentenceCase(s),
- value: s,
- icon: getRFQStatusIcon(s),
- count: statusCounts[s],
- };
- }),
-
- }
- ]
-
- /**
- * Advanced filter fields for the data table.
- */
- const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [
- {
- id: "rfqCode",
- label: "RFQ Code",
- type: "text",
- },
- {
- id: "description",
- label: "Description",
- type: "text",
- },
- {
- id: "projectCode",
- label: "Project Code",
- type: "text",
- },
- {
- id: "dueDate",
- label: "Due Date",
- type: "date",
- },
- {
- id: "status",
- label: "Status",
- type: "multi-select",
- options: rfqs.status.enumValues?.map((status) => {
- // 명시적으로 status를 허용된 리터럴 타입으로 변환
- const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
- return {
- label: toSentenceCase(s),
- value: s,
- icon: getRFQStatusIcon(s),
- count: statusCounts[s],
- };
- }),
-
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.rfqId),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <div style={{ maxWidth: '100vw' }}>
- <DataTable
- table={table}
- // floatingBar={<RfqsTableFloatingBar table={table} />}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <RfqsTableToolbarActions table={table} rfqType={rfqType} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <UpdateRfqSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- rfq={rowAction?.row.original ?? null}
- />
-
- <DeleteRfqsDialog
- open={rowAction?.type === "delete"}
- onOpenChange={() => setRowAction(null)}
- rfqs={rowAction?.row.original ? [rowAction?.row.original] : []}
- showTrigger={false}
- onSuccess={() => rowAction?.row.toggleSelected(false)}
- />
-
- <RfqsItemsDialog
- open={itemsModalOpen}
- onOpenChange={setItemsModalOpen}
- rfq={selectedRfq ?? null}
- itemsList={itemsList}
- defaultItems={itemsDefault}
- rfqType={rfqType}
- />
-
- <RfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachDefault}
- rfqType={rfqType}
- rfq={selectedRfq ?? null}
- onAttachmentsUpdated={handleAttachmentsUpdated}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx
deleted file mode 100644
index 22ca2c37..00000000
--- a/lib/rfqs/table/update-rfq-sheet.tsx
+++ /dev/null
@@ -1,406 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Input } from "@/components/ui/input"
-
-import { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
-import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations"
-import { modifyRfq, getBudgetaryRfqs } from "../service"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import { type Project } from "../service"
-import { ParentRfqSelector } from "./ParentRfqSelector"
-
-interface UpdateRfqSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- rfq: RfqWithItemCount | null
-}
-
-// 부모 RFQ 정보 타입 정의
-interface ParentRfq {
- id: number;
- rfqCode: string;
- description: string | null;
- rfqType: RfqType;
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
-}
-
-export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const { data: session } = useSession()
- const userId = Number(session?.user?.id || 1)
- const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null)
-
- // RFQ의 타입 가져오기
- const rfqType = rfq?.rfqType || RfqType.PURCHASE;
-
- // 초기 부모 RFQ ID 가져오기
- const initialParentRfqId = rfq?.parentRfqId;
-
- // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정
- const getParentRfqTypes = (): RfqType[] => {
- switch(rfqType) {
- case RfqType.PURCHASE:
- // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음
- return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY];
- case RfqType.PURCHASE_BUDGETARY:
- // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음
- return [RfqType.BUDGETARY];
- default:
- return [];
- }
- };
-
- // 부모 RFQ 타입들
- const parentRfqTypes = getParentRfqTypes();
-
- // 부모 RFQ를 보여줄지 결정
- const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY;
-
- // 타입에 따른 타이틀 생성
- const getTypeTitle = () => {
- switch(rfqType) {
- case RfqType.PURCHASE:
- return "Purchase RFQ";
- case RfqType.BUDGETARY:
- return "Budgetary RFQ";
- case RfqType.PURCHASE_BUDGETARY:
- return "Purchase Budgetary RFQ";
- default:
- return "RFQ";
- }
- };
-
- // 타입 설명 가져오기
- const getTypeDescription = () => {
- switch(rfqType) {
- case RfqType.PURCHASE:
- return "실제 구매 발주 전에 가격을 요청";
- case RfqType.BUDGETARY:
- return "기술영업 단계에서 입찰가 산정을 위한 견적 요청";
- case RfqType.PURCHASE_BUDGETARY:
- return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청";
- default:
- return "";
- }
- };
-
- // 부모 RFQ 선택기 레이블 및 설명 가져오기
- const getParentRfqSelectorLabel = () => {
- if (rfqType === RfqType.PURCHASE) {
- return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)";
- } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
- return "부모 RFQ (BUDGETARY)";
- }
- return "부모 RFQ";
- };
-
- const getParentRfqDescription = () => {
- if (rfqType === RfqType.PURCHASE) {
- return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다.";
- } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
- return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다.";
- }
- return "";
- };
-
- // 초기 부모 RFQ 로드
- React.useEffect(() => {
- if (initialParentRfqId && shouldShowParentRfqSelector) {
- const loadInitialParentRfq = async () => {
- try {
- const result = await getBudgetaryRfqs({
- rfqId: initialParentRfqId
- });
-
- if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) {
- setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq);
- }
- } catch (error) {
- console.error("부모 RFQ 로드 오류:", error);
- }
- };
-
- loadInitialParentRfq();
- }
- }, [initialParentRfqId, shouldShowParentRfqSelector]);
-
- // RHF setup
- const form = useForm<UpdateRfqSchema>({
- resolver: zodResolver(updateRfqSchema),
- defaultValues: {
- id: rfq?.rfqId ?? 0, // PK
- rfqCode: rfq?.rfqCode ?? "",
- description: rfq?.description ?? "",
- projectId: rfq?.projectId, // 프로젝트 ID
- parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID
- dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환
- status: rfq?.status ?? "DRAFT",
- createdBy: rfq?.createdBy ?? userId,
- },
- });
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project | null) => {
- if (project === null) {
- return;
- }
- form.setValue("projectId", project.id);
- };
-
- // 부모 RFQ 선택 처리
- const handleParentRfqSelect = (rfq: ParentRfq | null) => {
- setSelectedParentRfq(rfq);
- form.setValue("parentRfqId", rfq?.id);
- };
-
- async function onSubmit(input: UpdateRfqSchema) {
- startUpdateTransition(async () => {
- if (!rfq) return
-
- const { error } = await modifyRfq({
- ...input,
- rfqType: rfqType as RfqType,
-
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- form.reset()
- props.onOpenChange?.(false) // close the sheet
- toast.success("RFQ updated!")
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>Update {getTypeTitle()}</SheetTitle>
- <SheetDescription>
- Update the {getTypeTitle()} details and save the changes
- <div className="mt-1 text-xs text-muted-foreground">
- {getTypeDescription()}
- </div>
- </SheetDescription>
- </SheetHeader>
-
- {/* RHF Form */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
-
- {/* Hidden or code-based id field */}
- <FormField
- control={form.control}
- name="id"
- render={({ field }) => (
- <input type="hidden" {...field} />
- )}
- />
-
- {/* Hidden rfqType field */}
- {/* <FormField
- control={form.control}
- name="rfqType"
- render={({ field }) => (
- <input type="hidden" {...field} />
- )}
- /> */}
-
- {/* Project Selector - 재사용 컴포넌트 사용 */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Project</FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */}
- {shouldShowParentRfqSelector && (
- <FormField
- control={form.control}
- name="parentRfqId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{getParentRfqSelectorLabel()}</FormLabel>
- <FormControl>
- <ParentRfqSelector
- selectedRfqId={field.value as number | undefined}
- onRfqSelect={handleParentRfqSelect}
- rfqType={rfqType}
- parentRfqTypes={parentRfqTypes}
- placeholder={
- rfqType === RfqType.PURCHASE
- ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
- : "BUDGETARY RFQ 선택..."
- }
- />
- </FormControl>
- <div className="text-xs text-muted-foreground mt-1">
- {getParentRfqDescription()}
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* rfqCode */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Code</FormLabel>
- <FormControl>
- <Input placeholder="e.g. RFQ-2025-001" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* description */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Description</FormLabel>
- <FormControl>
- <Input placeholder="Description" {...field} value={field.value || ""} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* dueDate (type="date") */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Due Date</FormLabel>
- <FormControl>
- <Input
- type="date"
- // convert Date -> yyyy-mm-dd
- value={field.value ? field.value.toISOString().slice(0, 10) : ""}
- onChange={(e) => {
- const val = e.target.value
- field.onChange(val ? new Date(val + "T00:00:00") : undefined)
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* status (Select) */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <FormControl>
- <Select
- onValueChange={field.onChange}
- value={field.value ?? "DRAFT"}
- >
- <SelectTrigger className="capitalize">
- <SelectValue placeholder="Select status" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => (
- <SelectItem key={item} value={item} className="capitalize">
- {item}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* createdBy (hidden or read-only) */}
- <FormField
- control={form.control}
- name="createdBy"
- render={({ field }) => (
- <input type="hidden" {...field} />
- )}
- />
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isUpdatePending}>
- {isUpdatePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx
deleted file mode 100644
index b3cdbc60..00000000
--- a/lib/rfqs/tbe-table/comments-sheet.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput,
-} from "@/components/ui/dropzone"
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell,
-} from "@/components/ui/table"
-
-import { createRfqCommentWithAttachments } from "../service"
-import { formatDate } from "@/lib/utils"
-
-
-export interface TbeComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-// 1) props 정의
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: TbeComment[]
- currentUserId: number
- rfqId: number
- tbeId: number
- vendorId: number
- onCommentsUpdated?: (comments: TbeComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 2) 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional(), // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- tbeId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
- console.log("tbeId", tbeId)
-
- const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: [],
- },
- })
-
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles",
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> {c.createdAt ? formatDate(c.createdAt, "KR") : "-"}</TableCell>
- <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // (B) 파일 드롭
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
- // (C) Submit
- async function onSubmit(data: CommentFormValues) {
- if (!rfqId) return
- startTransition(async () => {
- try {
- console.log("rfqId", rfqId)
- console.log("vendorId", vendorId)
- console.log("tbeId", tbeId)
- console.log("currentUserId", currentUserId)
- const res = await createRfqCommentWithAttachments({
- rfqId,
- vendorId,
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: tbeId,
- cbeId: null,
- files: data.newFiles,
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 임시로 새 코멘트 추가
- const newComment: TbeComment = {
- id: res.commentId, // 서버 응답
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments:
- data.newFiles?.map((f) => ({
- id: Math.floor(Math.random() * 1e6),
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [],
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea placeholder="Enter your comment..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div
- key={field.id}
- className="flex items-center justify-between border rounded p-2"
- >
- <span className="text-sm">
- {file.name} ({prettyBytes(file.size)})
- </span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/feature-flags-provider.tsx b/lib/rfqs/tbe-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/rfqs/tbe-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/rfqs/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx
deleted file mode 100644
index e19430a3..00000000
--- a/lib/rfqs/tbe-table/file-dialog.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Download, X } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDateTime } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-
-import {
- FileList,
- FileListItem,
- FileListIcon,
- FileListInfo,
- FileListName,
- FileListDescription,
- FileListAction,
-} from "@/components/ui/file-list"
-import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service"
-
-interface TBEFileDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- tbeId: number
- vendorId: number
- rfqId: number
- onRefresh?: () => void
-}
-
-export function TBEFileDialog({
- isOpen,
- onOpenChange,
- vendorId,
- rfqId,
- onRefresh,
-}: TBEFileDialogProps) {
- const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([])
- const [isFetchingFiles, setIsFetchingFiles] = React.useState(false)
-
-
- // Fetch submitted files when dialog opens
- React.useEffect(() => {
- if (isOpen && rfqId && vendorId) {
- fetchSubmittedFiles()
- }
- }, [isOpen, rfqId, vendorId])
-
- // Fetch submitted files using the service function
- const fetchSubmittedFiles = async () => {
- if (!rfqId || !vendorId) return
-
- setIsFetchingFiles(true)
- try {
- const { files, error } = await getTbeFilesForVendor(rfqId, vendorId)
-
- if (error) {
- throw new Error(error)
- }
-
- setSubmittedFiles(files)
- } catch (error) {
- toast.error("Failed to load files: " + getErrorMessage(error))
- } finally {
- setIsFetchingFiles(false)
- }
- }
-
- // Download submitted file
- const downloadSubmittedFile = async (file: any) => {
- try {
- const response = await fetch(`/api/tbe-download?path=${encodeURIComponent(file.filePath)}`)
- if (!response.ok) {
- throw new Error("Failed to download file")
- }
-
- const blob = await response.blob()
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = file.fileName
- document.body.appendChild(a)
- a.click()
- window.URL.revokeObjectURL(url)
- document.body.removeChild(a)
- } catch (error) {
- toast.error("Failed to download file: " + getErrorMessage(error))
- }
- }
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-lg">
- <DialogHeader>
- <DialogTitle>TBE 응답 파일</DialogTitle>
- <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription>
- </DialogHeader>
-
- {/* 제출된 파일 목록 */}
- {isFetchingFiles ? (
- <div className="flex justify-center items-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
- </div>
- ) : submittedFiles.length > 0 ? (
- <div className="grid gap-2">
- <FileList>
- {submittedFiles.map((file) => (
- <FileListItem key={file.id} className="flex items-center justify-between gap-3">
- <div className="flex items-center gap-3 flex-1">
- <FileListIcon className="flex-shrink-0" />
- <FileListInfo className="flex-1 min-w-0">
- <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
- <FileListDescription className="text-xs text-muted-foreground">
- {file.uploadedAt ? formatDateTime(file.uploadedAt, "KR") : ""}
- </FileListDescription>
- </FileListInfo>
- </div>
- <FileListAction className="flex-shrink-0 ml-2">
- <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
- <Download className="h-4 w-4" />
- <span className="sr-only">파일 다운로드</span>
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
deleted file mode 100644
index 935d2bf3..00000000
--- a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Send } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { Input } from "@/components/ui/input"
-
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { inviteTbeVendorsAction } from "../service"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Badge } from "@/components/ui/badge"
-import { Label } from "@/components/ui/label"
-
-interface InviteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: Row<VendorWithTbeFields>["original"][]
- rfqId: number
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function InviteVendorsDialog({
- vendors,
- rfqId,
- showTrigger = true,
- onSuccess,
- ...props
-}: InviteVendorsDialogProps) {
- const [isInvitePending, startInviteTransition] = React.useTransition()
-
-
- // multiple 파일을 받을 state
- const [files, setFiles] = React.useState<FileList | null>(null)
-
- // 미디어쿼리 (desktop 여부)
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onInvite() {
- startInviteTransition(async () => {
- // 파일이 선택되지 않았다면 에러
- if (!files || files.length === 0) {
- toast.error("Please attach TBE files before inviting.")
- return
- }
-
- // FormData 생성
- const formData = new FormData()
- formData.append("rfqId", String(rfqId))
- vendors.forEach((vendor) => {
- formData.append("vendorIds[]", String(vendor.id))
- })
-
- // multiple 파일
- for (let i = 0; i < files.length; i++) {
- formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles"
- }
-
- // 서버 액션 호출
- const { error } = await inviteTbeVendorsAction(formData)
-
- if (error) {
- toast.error(error)
- return
- }
-
- // 성공
- props.onOpenChange?.(false)
- toast.success("Vendors invited with TBE!")
- onSuccess?.()
- })
- }
-
- // 파일 선택 UI
- const fileInput = (
-<>
- <div className="space-y-2">
- <Label>선택된 협력업체 ({vendors.length})</Label>
- <ScrollArea className="h-20 border rounded-md p-2">
- <div className="flex flex-wrap gap-2">
- {vendors.map((vendor, index) => (
- <Badge key={index} variant="secondary" className="py-1">
- {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
- </Badge>
- ))}
- </div>
- </ScrollArea>
- <p className="text-[0.8rem] font-medium text-muted-foreground">
- 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다.
- </p>
- </div>
-
- <div className="mb-4">
- <label className="mb-2 block font-medium">TBE Sheets</label>
- <Input
- type="file"
- multiple
- onChange={(e) => {
- setFiles(e.target.files)
- }}
- />
- </div>
- </>
- )
-
- // Desktop Dialog
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- TBE 평가 생성 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>TBE 평가 시트 전송</DialogTitle>
- <DialogDescription>
- 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 파일 첨부 */}
- {fileInput}
-
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">Cancel</Button>
- </DialogClose>
- <Button
- aria-label="Invite selected rows"
- variant="destructive"
- onClick={onInvite}
- // 파일이 없거나 초대 진행중이면 비활성화
- disabled={isInvitePending || !files || files.length === 0}
- >
- {isInvitePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- Invite
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- // Mobile Drawer
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DialogTitle>TBE 평가 시트 전송</DialogTitle>
- <DialogDescription>
- 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
- </DialogDescription>
- </DrawerHeader>
-
- {/* 파일 첨부 */}
- {fileInput}
-
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
- </DrawerClose>
- <Button
- aria-label="Invite selected rows"
- variant="destructive"
- onClick={onInvite}
- // 파일이 없거나 초대 진행중이면 비활성화
- disabled={isInvitePending || !files || files.length === 0}
- >
- {isInvitePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- Invite
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-result-dialog.tsx b/lib/rfqs/tbe-table/tbe-result-dialog.tsx
deleted file mode 100644
index 8400ecac..00000000
--- a/lib/rfqs/tbe-table/tbe-result-dialog.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { getErrorMessage } from "@/lib/handle-error"
-import { saveTbeResult } from "../service"
-
-// Define the props for the TbeResultDialog component
-interface TbeResultDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- tbe: VendorWithTbeFields | null
- onRefresh?: () => void
-}
-
-// Define TBE result options
-const TBE_RESULT_OPTIONS = [
- { value: "pass", label: "Pass", badgeVariant: "default" },
- { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" },
- { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" },
-] as const
-
-type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"]
-
-export function TbeResultDialog({
- open,
- onOpenChange,
- tbe,
- onRefresh,
-}: TbeResultDialogProps) {
- // Initialize state for form inputs
- const [result, setResult] = React.useState<TbeResultOption | "">("")
- const [note, setNote] = React.useState("")
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // Update form values when the tbe prop changes
- React.useEffect(() => {
- if (tbe) {
- setResult((tbe.tbeResult as TbeResultOption) || "")
- setNote(tbe.tbeNote || "")
- }
- }, [tbe])
-
- // Reset form when dialog closes
- React.useEffect(() => {
- if (!open) {
- // Small delay to avoid visual glitches when dialog is closing
- const timer = setTimeout(() => {
- if (!tbe) {
- setResult("")
- setNote("")
- }
- }, 300)
- return () => clearTimeout(timer)
- }
- }, [open, tbe])
-
- // Handle form submission with server action
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
-
- if (!tbe || !result) return
-
- setIsSubmitting(true)
-
- try {
- // Call the server action to save the TBE result
- const response = await saveTbeResult({
- id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table
- vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table
- result: result, // The selected evaluation result
- notes: note, // The evaluation notes
- })
-
- if (!response.success) {
- throw new Error(response.message || "Failed to save TBE result")
- }
-
- // Show success toast
- toast.success("TBE result saved successfully")
-
- // Close the dialog
- onOpenChange(false)
-
- // Refresh the data if refresh callback is provided
- if (onRefresh) {
- onRefresh()
- }
- } catch (error) {
- // Show error toast
- toast.error(`Failed to save: ${getErrorMessage(error)}`)
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // Find the selected result option
- const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result)
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle className="text-xl font-semibold">
- {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"}
- </DialogTitle>
- {tbe && (
- <DialogDescription className="text-sm text-muted-foreground mt-1">
- <div className="flex flex-col gap-1">
- <span>
- <strong>Vendor:</strong> {tbe.vendorName}
- </span>
- <span>
- <strong>RFQ Code:</strong> {tbe.rfqCode}
- </span>
- {tbe.email && (
- <span>
- <strong>Email:</strong> {tbe.email}
- </span>
- )}
- </div>
- </DialogDescription>
- )}
- </DialogHeader>
-
- <form onSubmit={handleSubmit} className="space-y-6 py-2">
- <div className="space-y-2">
- <Label htmlFor="tbe-result" className="text-sm font-medium">
- Evaluation Result
- </Label>
- <Select
- value={result}
- onValueChange={(value) => setResult(value as TbeResultOption)}
- required
- >
- <SelectTrigger id="tbe-result" className="w-full">
- <SelectValue placeholder="Select a result" />
- </SelectTrigger>
- <SelectContent>
- {TBE_RESULT_OPTIONS.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- <div className="flex items-center">
- <Badge variant={option.badgeVariant as any} className="mr-2">
- {option.label}
- </Badge>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="tbe-note" className="text-sm font-medium">
- Evaluation Note
- </Label>
- <Textarea
- id="tbe-note"
- placeholder="Enter evaluation notes..."
- value={note}
- onChange={(e) => setNote(e.target.value)}
- className="min-h-[120px] resize-y"
- />
- </div>
-
- <DialogFooter className="gap-2 sm:gap-0">
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={!result || isSubmitting}
- className="min-w-[100px]"
- >
- {isSubmitting ? "Saving..." : "Save"}
- </Button>
- </DialogFooter>
- </form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx
deleted file mode 100644
index 0538d354..00000000
--- a/lib/rfqs/tbe-table/tbe-table-columns.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Ellipsis, MessageSquare } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-import {
- vendorTbeColumnsConfig,
- VendorWithTbeFields,
-} from "@/config/vendorTbeColumnsConfig"
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>
- >
- router: NextRouter
- openCommentSheet: (vendorId: number) => void
- openFilesDialog: (tbeId:number , vendorId: number) => void
- openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처
-
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- openCommentSheet,
- openFilesDialog,
- openVendorContactsDialog
-}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorWithTbeFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {}
-
- vendorTbeColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<VendorWithTbeFields>
- const childCol: ColumnDef<VendorWithTbeFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
- if (cfg.id === "vendorName") {
- const vendor = row.original;
- const vendorId = vendor.vendorId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (vendorId) {
- openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
-
- if (cfg.id === "tbeResult") {
- const vendor = row.original;
- const tbeResult = vendor.tbeResult;
- const filesCount = vendor.files?.length ?? 0;
-
- // Only show button or link if there are files
- if (filesCount > 0) {
- // Function to handle clicking on the result
- const handleTbeResultClick = () => {
- setRowAction({ row, type: "tbeResult" });
- };
-
- if (!tbeResult) {
- // No result yet, but files exist - show "결과 입력" button
- return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleTbeResultClick}
- >
- 결과 입력
- </Button>
- );
- } else {
- // Result exists - show as a hyperlink
- let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline";
-
- // Set badge variant based on result
- if (tbeResult === "pass") {
- badgeVariant = "default";
- } else if (tbeResult === "non-pass") {
- badgeVariant = "destructive";
- } else if (tbeResult === "conditional pass") {
- badgeVariant = "secondary";
- }
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto underline"
- onClick={handleTbeResultClick}
- >
- <Badge variant={badgeVariant}>
- {tbeResult}
- </Badge>
- </Button>
- );
- }
- }
-
- // No files available, return empty cell
- return null;
- }
-
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
-
-
- // 예) TBE Updated (날짜)
- if (cfg.id === "tbeUpdated") {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal, "KR")
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<VendorWithTbeFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
-// ----------------------------------------------------------------
-// 3) Comments 컬럼
-// ----------------------------------------------------------------
-const commentsColumn: ColumnDef<VendorWithTbeFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // rowAction + openCommentSheet
- setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.tbeId ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize:80
-}
-
- // ----------------------------------------------------------------
- // 4) Actions 컬럼 (예: 초대하기 버튼)
- // ----------------------------------------------------------------
- // const actionsColumn: ColumnDef<VendorWithTbeFields> = {
- // id: "actions",
- // cell: ({ row }) => {
- // const status = row.original.tbeResult
- // // 예: 만약 tbeResult가 없을 때만 초대하기 버튼 표시
- // if (status) {
- // return null
- // }
-
- // return (
- // <Button
- // onClick={() => setRowAction({ row, type: "invite" })}
- // size="sm"
- // variant="outline"
- // >
- // 발행하기
- // </Button>
- // )
- // },
- // size: 80,
- // enableSorting: false,
- // enableHiding: false,
- // }
-// ----------------------------------------------------------------
-// 3) Files Column - Add before Comments column
-// ----------------------------------------------------------------
-const filesColumn: ColumnDef<VendorWithTbeFields> = {
- id: "files",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Response Files" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- // We'll assume that files count will be populated from the backend
- // You'll need to modify your getTBE function to include files
- const filesCount = vendor.files?.length ?? 0
-
- function handleClick() {
- // Open files dialog
- setRowAction({ row, type: "files" })
- openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0)
- }
-
- return (
- <div className="flex items-center justify-center">
-<Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"}
->
- {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */}
- <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
-
- {/* 파일 개수가 1개 이상이면 뱃지 표시 */}
- {filesCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {filesCount}
- </Badge>
- )}
-
- <span className="sr-only">
- {filesCount > 0 ? `${filesCount} Files` : "Upload File"}
- </span>
-</Button>
- </div>
- )
- },
- enableSorting: false,
- maxSize: 80
-}
-
-// ----------------------------------------------------------------
-// 5) 최종 컬럼 배열 - Update to include the files column
-// ----------------------------------------------------------------
-return [
- selectColumn,
- ...nestedColumns,
- filesColumn, // Add the files column before comments
- commentsColumn,
- // actionsColumn,
-]
-
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
deleted file mode 100644
index a8f8ea82..00000000
--- a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-
-
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<VendorWithTbeFields>
- rfqId: number
-}
-
-export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일이 선택되었을 때 처리
-
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
- }
-
- const invitationPossibeVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original)
- .filter(vendor => vendor.technicalResponseStatus === null);
- }, [table.getFilteredSelectedRowModel().rows]);
-
- return (
- <div className="flex items-center gap-2">
- {invitationPossibeVendors.length > 0 &&
- (
- <InviteVendorsDialog
- vendors={invitationPossibeVendors}
- rfqId = {rfqId}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- )
- }
-
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx
deleted file mode 100644
index 0add8927..00000000
--- a/lib/rfqs/tbe-table/tbe-table.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./tbe-table-columns"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions"
-import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { CommentSheet, TbeComment } from "./comments-sheet"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { TBEFileDialog } from "./file-dialog"
-import { TbeResultDialog } from "./tbe-result-dialog"
-import { VendorContactsDialog } from "./vendor-contact-dialog"
-import { useSession } from "next-auth/react" // Next-auth session hook 추가
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getTBE>>,
- ]
- >
- rfqId: number
-}
-
-
-export function TbeTable({ promises, rfqId }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- console.log("data", data)
- const { data: session } = useSession() // 세션 정보 가져오기
-
- const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
-
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
- const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
- const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
- const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null)
-
- // Add handleRefresh function
- const handleRefresh = React.useCallback(() => {
- router.refresh();
- }, [router]);
-
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.id))
- } else if (rowAction?.type === "files") {
- // Handle files action
- const vendorId = rowAction.row.original.vendorId;
- const tbeId = rowAction.row.original.tbeId ?? 0;
- openFilesDialog(tbeId, vendorId);
- }
- }, [rowAction])
-
- async function openCommentSheet(a: number) {
- setInitialComments([])
-
- const comments = rowAction?.row.original.comments
- const rfqId = rowAction?.row.original.rfqId
- const vendorId = rowAction?.row.original.vendorId
- const tbeId = rowAction?.row.original.tbeId
- console.log("original", rowAction?.row.original)
- if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
-
- return {
- ...c,
- commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
- // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
- setInitialComments(commentWithAttachments)
- }
- setSelectedTbeId(tbeId ?? 0)
- setSelectedVendorId(vendorId ?? 0)
- setSelectedRfqIdForComments(rfqId ?? 0)
- setCommentSheetOpen(true)
- }
-
- const openFilesDialog = (tbeId: number, vendorId: number) => {
- setSelectedTbeId(tbeId)
- setSelectedVendorId(vendorId)
- setIsFileDialogOpen(true)
- }
- const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => {
- setSelectedVendorId(vendorId)
- setSelectedVendor(vendor)
- setIsContactDialogOpen(true)
- }
-
- // getColumns() 호출 시, router를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
- {
- id: "vendorStatus",
- label: "Vendor Status",
- type: "multi-select",
- options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
- value: status,
- })),
- },
- { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
- ]
-
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["comments"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
-
-
- return (
- <div style={{ maxWidth: '80vw' }}>
- <DataTable
- table={table}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- <InviteVendorsDialog
- vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
- onOpenChange={() => setRowAction(null)}
- rfqId={rfqId}
- open={rowAction?.type === "invite"}
- showTrigger={false}
- />
- <CommentSheet
- currentUserId={currentUserId}
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- rfqId={rfqId}
- tbeId={selectedTbeId ?? 0}
- vendorId={selectedVendorId ?? 0}
- initialComments={initialComments}
- />
-
- <TBEFileDialog
- isOpen={isFileDialogOpen}
- onOpenChange={setIsFileDialogOpen}
- tbeId={selectedTbeId ?? 0}
- vendorId={selectedVendorId ?? 0}
- rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId
- onRefresh={handleRefresh}
- />
-
- <TbeResultDialog
- open={rowAction?.type === "tbeResult"}
- onOpenChange={() => setRowAction(null)}
- tbe={rowAction?.row.original ?? null}
- />
-
- <VendorContactsDialog
- isOpen={isContactDialogOpen}
- onOpenChange={setIsContactDialogOpen}
- vendorId={selectedVendorId}
- vendor={selectedVendor}
- />
-
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx
deleted file mode 100644
index 3619fe77..00000000
--- a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { VendorContactsTable } from "./vendor-contact/vendor-contact-table"
-import { Badge } from "@/components/ui/badge"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-
-interface VendorContactsDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- vendorId: number | null
- vendor: VendorWithTbeFields | null
-}
-
-export function VendorContactsDialog({
- isOpen,
- onOpenChange,
- vendorId,
- vendor,
-}: VendorContactsDialogProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
- <DialogHeader>
- <div className="flex flex-col space-y-2">
- <DialogTitle>협력업체 연락처</DialogTitle>
- {vendor && (
- <div className="flex flex-col space-y-1 mt-2">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium text-foreground">{vendor.vendorName}</span>
- {vendor.vendorCode && (
- <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
- )}
- </div>
- <div className="flex items-center">
- {vendor.vendorStatus && (
- <Badge variant="outline" className="mr-2">
- {vendor.vendorStatus}
- </Badge>
- )}
- {vendor.rfqVendorStatus && (
- <Badge
- variant={
- vendor.rfqVendorStatus === "INVITED" ? "default" :
- vendor.rfqVendorStatus === "DECLINED" ? "destructive" :
- vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline"
- }
- >
- {vendor.rfqVendorStatus}
- </Badge>
- )}
- </div>
- </div>
- )}
- </div>
- </DialogHeader>
- {vendorId && (
- <div className="py-4">
- <VendorContactsTable vendorId={vendorId} />
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx
deleted file mode 100644
index efc395b4..00000000
--- a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-// Because columns rely on React state/hooks for row actions
-
-import * as React from "react"
-import { ColumnDef, Row } from "@tanstack/react-table"
-import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
-import { formatDate } from "@/lib/utils"
-import { Checkbox } from "@/components/ui/checkbox"
-import { VendorData } from "./vendor-contact-table"
-
-
-/** getColumns: return array of ColumnDef for 'vendors' data */
-export function getColumns(): ColumnDef<VendorData>[] {
- return [
-
- // Vendor Name
- {
- accessorKey: "contactName",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" />
- ),
- cell: ({ row }) => row.getValue("contactName"),
- },
-
- // Vendor Code
- {
- accessorKey: "contactPosition",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Position" />
- ),
- cell: ({ row }) => row.getValue("contactPosition"),
- },
-
- // Status
- {
- accessorKey: "contactEmail",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Email" />
- ),
- cell: ({ row }) => row.getValue("contactEmail"),
- },
-
- // Country
- {
- accessorKey: "contactPhone",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
- ),
- cell: ({ row }) => row.getValue("contactPhone"),
- },
-
- // Created At
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"),
- },
-
- // Updated At
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"),
- },
- ]
-} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx
deleted file mode 100644
index c079da02..00000000
--- a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-'use client'
-
-import * as React from "react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { getColumns } from "./vendor-contact-table-column"
-import { DataTableAdvancedFilterField } from "@/types/table"
-import { Loader2 } from "lucide-react"
-import { useToast } from "@/hooks/use-toast"
-import { getVendorContactsByVendorId } from "../../service"
-
-export interface VendorData {
- id: number
- contactName: string
- contactPosition: string | null
- contactEmail: string
- contactPhone: string | null
- isPrimary: boolean | null
- createdAt: Date
- updatedAt: Date
-}
-
-interface VendorContactsTableProps {
- vendorId: number
-}
-
-export function VendorContactsTable({ vendorId }: VendorContactsTableProps) {
- const { toast } = useToast()
-
- const columns = React.useMemo(
- () => getColumns(),
- []
- )
-
- const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- React.useEffect(() => {
- async function loadVendorContacts() {
- setIsLoading(true)
- try {
- const result = await getVendorContactsByVendorId(vendorId)
- if (result.success && result.data) {
- // undefined 체크 추가 및 타입 캐스팅
- setVendorContacts(result.data as VendorData[])
- } else {
- throw new Error(result.error || "Unknown error occurred")
- }
- } catch (error) {
- console.error("협력업체 연락처 로드 오류:", error)
- toast({
- title: "Error",
- description: "Failed to load vendor contacts",
- variant: "destructive",
- })
- } finally {
- setIsLoading(false)
- }
- }
- loadVendorContacts()
- }, [toast, vendorId])
-
- const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [
- { id: "contactName", label: "Contact Name", type: "text" },
- { id: "contactPosition", label: "Posiotion", type: "text" },
- { id: "contactEmail", label: "Email", type: "text" },
- { id: "contactPhone", label: "Phone", type: "text" },
-
-
- ]
-
- // If loading, show a flex container that fills the parent and centers the spinner
- if (isLoading) {
- return (
- <div className="flex h-full w-full items-center justify-center">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- </div>
- )
- }
-
- // Otherwise, show the table
- return (
- <ClientDataTable
- data={vendorContacts}
- columns={columns}
- advancedFilterFields={advancedFilterFields}
- >
- </ClientDataTable>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts
deleted file mode 100644
index 8752f693..00000000
--- a/lib/rfqs/validations.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import { createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,parseAsBoolean
-} from "nuqs/server"
-import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq";
-import { Vendor, vendors } from "@/db/schema/vendors";
-
-export const RfqType = {
- PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY",
- PURCHASE: "PURCHASE",
- BUDGETARY: "BUDGETARY"
-} as const;
-
-export type RfqType = typeof RfqType[keyof typeof RfqType];
-
-// =======================
-// 1) SearchParams (목록 필터링/정렬)
-// =======================
-export const searchParamsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RfqsView>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 간단 검색 필드
- rfqCode: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- dueDate: parseAsString.withDefault(""),
-
- // 상태 - 여러 개일 수 있다고 가정
- status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- search: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
-
-});
-
-export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
-
-
-export const searchParamsMatchedVCache = createSearchParamsCache({
- // 1) 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 2) 페이지네이션
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 3) 정렬 (Rfq 테이블)
- // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사
- sort: getSortingStateParser<VendorRfqViewBase>().withDefault([
- { id: "rfqVendorUpdated", desc: true },
- ]),
-
- // 4) 간단 검색 필드
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- country: parseAsString.withDefault(""),
- email: parseAsString.withDefault(""),
- website: parseAsString.withDefault(""),
-
- // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
- // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
- vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
-
- // 6) 고급 필터 (nuqs - filterColumns)
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 7) 글로벌 검색어
- search: parseAsString.withDefault(""),
-})
-export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>;
-
-export const searchParamsTBECache = createSearchParamsCache({
- // 1) 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 2) 페이지네이션
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 3) 정렬 (Rfq 테이블)
- // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사
- sort: getSortingStateParser<VendorTbeView>().withDefault([
- { id: "tbeUpdated", desc: true },
- ]),
-
- // 4) 간단 검색 필드
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- country: parseAsString.withDefault(""),
- email: parseAsString.withDefault(""),
- website: parseAsString.withDefault(""),
-
- tbeResult: parseAsString.withDefault(""),
- tbeNote: parseAsString.withDefault(""),
- tbeUpdated: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
-
- // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
- // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
- vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
-
- // 6) 고급 필터 (nuqs - filterColumns)
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 7) 글로벌 검색어
- search: parseAsString.withDefault(""),
-})
-export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>;
-
-// =======================
-// 2) Create RFQ Schema
-// =======================
-export const createRfqSchema = z.object({
- rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"),
- description: z.string().optional(),
- projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적)
- bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적)
- parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
- dueDate: z.date(),
- status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
- rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]).default(RfqType.PURCHASE),
- createdBy: z.number(),
-});
-
-export type CreateRfqSchema = z.infer<typeof createRfqSchema>;
-
-export const createRfqItemSchema = z.object({
- rfqId: z.number().int().min(1, "Invalid RFQ ID"),
- itemCode: z.string().min(1),
- itemName: z.string().optional(),
- description: z.string().optional(),
- quantity: z.number().min(1).optional(),
- uom: z.string().optional(),
- rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가
-
-});
-
-export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>;
-
-// =======================
-// 3) Update RFQ Schema
-// (현재 코드엔 updateTaskSchema라고 되어 있는데,
-// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움)
-// =======================
-export const updateRfqSchema = z.object({
- // PK id -> 실제로는 URL params로 받을 수도 있지만,
- // 여기서는 body에서 받는다고 가정
- id: z.number().int().min(1, "Invalid ID"),
-
- // 업데이트 시 대부분 optional
- rfqCode: z.string().max(50).optional(),
- projectId: z.number().nullable().optional(), // null 값도 허용
- description: z.string().optional(),
- parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
- dueDate: z.preprocess(
- // null이나 빈 문자열을 undefined로 변환
- (val) => (val === null || val === '') ? undefined : val,
- z.date().optional()
- ),
- rfqType: z.enum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).optional(),
- status: z.union([
- z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
- z.string().refine(
- (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val),
- { message: "Invalid status value" }
- )
- ]).optional(),
- createdBy: z.number().int().min(1).optional(),
-});
-export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>;
-
-export const searchParamsRfqsForVendorsCache = createSearchParamsCache({
- // 1) 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 2) 페이지네이션
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 3) 정렬 (rfqs 테이블)
- sort: getSortingStateParser<Rfq>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등)
- rfqCode: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
-
- // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...)
- status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]),
-
- // 6) 고급 필터 (nuqs filterColumns)
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 7) 글로벌 검색어
- search: parseAsString.withDefault(""),
-})
-
-/**
- * 최종 타입
- * `Awaited<ReturnType<...parse>>` 형태로
- * Next.js 13 서버 액션이나 클라이언트에서 사용 가능
- */
-export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>>
-
-export const updateRfqVendorSchema = z.object({
- id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id
- status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"])
-})
-
-export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema>
-
-
-export const searchParamsCBECache = createSearchParamsCache({
- // 1) 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 2) 페이지네이션
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 3) 정렬 (VendorResponseCBEView 테이블)
- // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤
- sort: getSortingStateParser<VendorResponseCBEView>().withDefault([
- { id: "totalPrice", desc: true },
- ]),
-
- // 4) 간단 검색 필드 - 기본 정보
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- country: parseAsString.withDefault(""),
- email: parseAsString.withDefault(""),
- website: parseAsString.withDefault(""),
-
- // CBE 관련 필드
- commercialResponseId: parseAsString.withDefault(""),
- totalPrice: parseAsString.withDefault(""),
- currency: parseAsString.withDefault(""),
- paymentTerms: parseAsString.withDefault(""),
- incoterms: parseAsString.withDefault(""),
- deliveryPeriod: parseAsString.withDefault(""),
- warrantyPeriod: parseAsString.withDefault(""),
- validityPeriod: parseAsString.withDefault(""),
-
- // RFQ 관련 필드
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
-
- // 응답 상태
- responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"),
-
- // 5) 상태 (배열) - vendor 상태
- vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
-
- // 6) 고급 필터 (nuqs - filterColumns)
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 7) 글로벌 검색어
- search: parseAsString.withDefault(""),
-
- // 8) 첨부파일 관련 필터
- hasAttachments: parseAsBoolean.withDefault(false),
-
- // 9) 날짜 범위 필터
- respondedAtRange: parseAsString.withDefault(""),
- commercialUpdatedAtRange: parseAsString.withDefault(""),
-})
-
-export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>;
-
-
-export const createCbeEvaluationSchema = z.object({
- paymentTerms: z.string().min(1, "지급 조건을 입력하세요"),
- incoterms: z.string().min(1, "Incoterms를 입력하세요"),
- deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
- notes: z.string().optional(),
-})
-
-// 타입 추출
-export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema> \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/add-vendor-dialog.tsx b/lib/rfqs/vendor-table/add-vendor-dialog.tsx
deleted file mode 100644
index 8ec5b9f4..00000000
--- a/lib/rfqs/vendor-table/add-vendor-dialog.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { VendorsListTable } from "./vendor-list/vendor-list-table"
-
-interface VendorsListTableProps {
- rfqId: number // so we know which RFQ to insert into
- }
-
-
-/**
- * A dialog that contains a client-side table or infinite scroll
- * for "all vendors," allowing the user to select vendors and add them to the RFQ.
- */
-export function AddVendorDialog({ rfqId }: VendorsListTableProps) {
- const [open, setOpen] = React.useState(false)
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button size="sm">
- Add Vendor
- </Button>
- </DialogTrigger>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}>
- <DialogHeader>
- <DialogTitle>Add Vendor to RFQ</DialogTitle>
- </DialogHeader>
-
- <VendorsListTable rfqId={rfqId}/>
-
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx
deleted file mode 100644
index 441fdcf1..00000000
--- a/lib/rfqs/vendor-table/comments-sheet.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput,
-} from "@/components/ui/dropzone"
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell,
-} from "@/components/ui/table"
-
-import { createRfqCommentWithAttachments } from "../service"
-import { formatDate } from "@/lib/utils"
-
-
-export interface MatchedVendorComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-// 1) props 정의
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: MatchedVendorComment[]
- currentUserId: number
- rfqId: number
- vendorId: number
- onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 2) 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional(), // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
-
- const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: [],
- },
- })
-
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles",
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
- <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // (B) 파일 드롭
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
- // (C) Submit
- async function onSubmit(data: CommentFormValues) {
- if (!rfqId) return
- startTransition(async () => {
- try {
- const res = await createRfqCommentWithAttachments({
- rfqId,
- vendorId,
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null,
- cbeId: null,
- files: data.newFiles,
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 임시로 새 코멘트 추가
- const newComment: MatchedVendorComment = {
- id: res.commentId, // 서버 응답
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments:
- data.newFiles?.map((f) => ({
- id: Math.floor(Math.random() * 1e6),
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [],
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea placeholder="Enter your comment..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div
- key={field.id}
- className="flex items-center justify-between border rounded p-2"
- >
- <span className="text-sm">
- {file.name} ({prettyBytes(file.size)})
- </span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/feature-flags-provider.tsx b/lib/rfqs/vendor-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/rfqs/vendor-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/rfqs/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
deleted file mode 100644
index 23853e2f..00000000
--- a/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Send, Trash, AlertTriangle } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-
-import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
-import { inviteVendors } from "../service"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface DeleteTasksDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: Row<MatchedVendorRow>["original"][]
- rfqId:number
- rfqType: RfqType
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function InviteVendorsDialog({
- vendors,
- rfqId,
- rfqType,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteTasksDialogProps) {
- const [isInvitePending, startInviteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startInviteTransition(async () => {
- const { error } = await inviteVendors({
- rfqId,
- vendorIds: vendors.map((vendor) => Number(vendor.id)),
- rfqType
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("Vendor invited")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Are you absolutely sure?</DialogTitle>
- <DialogDescription>
- This action cannot be undone. This will permanently invite{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}.
- </DialogDescription>
- </DialogHeader>
-
- {/* 편집 제한 경고 메시지 */}
- <Alert variant="destructive" className="mt-4">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription className="font-medium">
- 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
- </AlertDescription>
- </Alert>
-
- <DialogFooter className="gap-2 sm:space-x-0 mt-6">
- <DialogClose asChild>
- <Button variant="outline">Cancel</Button>
- </DialogClose>
- <Button
- aria-label="Invite selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isInvitePending}
- >
- {isInvitePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- Invite
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>Are you absolutely sure?</DrawerTitle>
- <DrawerDescription>
- This action cannot be undone. This will permanently invite {" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"} from our servers.
- </DrawerDescription>
- </DrawerHeader>
-
- {/* 편집 제한 경고 메시지 (모바일용) */}
- <div className="px-4">
- <Alert variant="destructive">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription className="font-medium">
- 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
- </AlertDescription>
- </Alert>
- </div>
-
- <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
- <DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected rows"
- variant="destructive"
- onClick={onDelete}
- disabled={isInvitePending}
- >
- {isInvitePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- Invite
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx
deleted file mode 100644
index bfcbe75b..00000000
--- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-"use client"
-// Because columns rely on React state/hooks for row actions
-
-import * as React from "react"
-import { ColumnDef, Row } from "@tanstack/react-table"
-import { VendorData } from "./vendor-list-table"
-import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
-import { formatDate } from "@/lib/utils"
-import { Checkbox } from "@/components/ui/checkbox"
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "open" | "update" | "delete"
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>>
- setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array
-}
-
-/** getColumns: return array of ColumnDef for 'vendors' data */
-export function getColumns({
- setRowAction,
- setSelectedVendorIds, // Changed parameter name
-}: GetColumnsProps): ColumnDef<VendorData>[] {
- return [
- // MULTIPLE SELECT COLUMN
- {
- id: "select",
- enableSorting: false,
- enableHiding: false,
- size: 40,
- // Add checkbox in header for select all functionality
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getFilteredSelectedRowModel().rows.length > 0 &&
- table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length
- }
- onCheckedChange={(checked) => {
- table.toggleAllRowsSelected(!!checked)
-
- // Update selectedVendorIds based on all rows selection
- if (checked) {
- const allIds = table.getFilteredRowModel().rows.map(row => row.original.id)
- setSelectedVendorIds(allIds)
- } else {
- setSelectedVendorIds([])
- }
- }}
- aria-label="Select all"
- />
- ),
- cell: ({ row }) => {
- const isSelected = row.getIsSelected()
-
- return (
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) => {
- row.toggleSelected(!!checked)
-
- // Update the selectedVendorIds state by adding or removing this ID
- setSelectedVendorIds(prevIds => {
- if (checked) {
- // Add this ID if it doesn't exist
- return prevIds.includes(row.original.id)
- ? prevIds
- : [...prevIds, row.original.id]
- } else {
- // Remove this ID
- return prevIds.filter(id => id !== row.original.id)
- }
- })
- }}
- aria-label="Select row"
- />
- )
- },
- },
-
- // Vendor Name
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" />
- ),
- cell: ({ row }) => row.getValue("vendorName"),
- },
-
- // Vendor Code
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" />
- ),
- cell: ({ row }) => row.getValue("vendorCode"),
- },
-
- // Status
- {
- accessorKey: "status",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Status" />
- ),
- cell: ({ row }) => row.getValue("status"),
- },
-
- // Country
- {
- accessorKey: "country",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Country" />
- ),
- cell: ({ row }) => row.getValue("country"),
- },
-
- // Email
- {
- accessorKey: "email",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Email" />
- ),
- cell: ({ row }) => row.getValue("email"),
- },
-
- // Phone
- {
- accessorKey: "phone",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
- ),
- cell: ({ row }) => row.getValue("phone"),
- },
-
- // Created At
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date),
- },
-
- // Updated At
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date),
- },
- ]
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
deleted file mode 100644
index e34a5052..00000000
--- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { DataTableRowAction, getColumns } from "./vendor-list-table-column"
-import { DataTableAdvancedFilterField } from "@/types/table"
-import { addItemToVendors, getAllVendors } from "../../service"
-import { Loader2, Plus } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { useToast } from "@/hooks/use-toast"
-
-export interface VendorData {
- id: number
- vendorName: string
- vendorCode: string | null
- taxId: string
- address: string | null
- country: string | null
- phone: string | null
- email: string | null
- website: string | null
- status: string
- createdAt: Date
- updatedAt: Date
-}
-
-interface VendorsListTableProps {
- rfqId: number
-}
-
-export function VendorsListTable({ rfqId }: VendorsListTableProps) {
- const { toast } = useToast()
- const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<VendorData> | null>(null)
-
- // Changed to array for multiple selection
- const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([])
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- const columns = React.useMemo(
- () => getColumns({ setRowAction, setSelectedVendorIds }),
- [setRowAction, setSelectedVendorIds]
- )
-
- const [vendors, setVendors] = React.useState<VendorData[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- React.useEffect(() => {
- async function loadAllVendors() {
- setIsLoading(true)
- try {
- const allVendors = await getAllVendors()
- setVendors(allVendors)
- } catch (error) {
- console.error("협력업체 목록 로드 오류:", error)
- toast({
- title: "Error",
- description: "Failed to load vendors",
- variant: "destructive",
- })
- } finally {
- setIsLoading(false)
- }
- }
- loadAllVendors()
- }, [toast])
-
- const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = []
-
- async function handleAddVendors() {
- if (selectedVendorIds.length === 0) return // Safety check
-
- setIsSubmitting(true)
- try {
- // Update to use the multiple vendor service
- const result = await addItemToVendors(rfqId, selectedVendorIds)
-
- if (result.success) {
- toast({
- title: "Success",
- description: `Added items to ${selectedVendorIds.length} vendors`,
- })
- // Reset selection after successful addition
- setSelectedVendorIds([])
- } else {
- toast({
- title: "Error",
- description: result.error || "Failed to add items to vendors",
- variant: "destructive",
- })
- }
- } catch (err) {
- console.error("Failed to add vendors:", err)
- toast({
- title: "Error",
- description: "An unexpected error occurred",
- variant: "destructive",
- })
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // If loading, show a flex container that fills the parent and centers the spinner
- if (isLoading) {
- return (
- <div className="flex h-full w-full items-center justify-center">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- </div>
- )
- }
-
- // Otherwise, show the table
- return (
- <ClientDataTable
- data={vendors}
- columns={columns}
- advancedFilterFields={advancedFilterFields}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="default"
- size="sm"
- onClick={handleAddVendors}
- disabled={selectedVendorIds.length === 0 || isSubmitting}
- >
- {isSubmitting ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- Adding...
- </>
- ) : (
- <>
- <Plus className="mr-2 h-4 w-4" />
- Add Vendors ({selectedVendorIds.length})
- </>
- )}
- </Button>
- </div>
- </ClientDataTable>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx
deleted file mode 100644
index f152cec5..00000000
--- a/lib/rfqs/vendor-table/vendors-table-columns.tsx
+++ /dev/null
@@ -1,276 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, MessageSquare } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { useRouter } from "next/navigation"
-
-import { vendors } from "@/db/schema/vendors"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
-import { Separator } from "@/components/ui/separator"
-import { MatchedVendorRow, vendorRfqColumnsConfig } from "@/config/vendorRfbColumnsConfig"
-
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<MatchedVendorRow> | null>>;
- router: NextRouter;
- openCommentSheet: (rfqId: number) => void;
-
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<MatchedVendorRow>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<MatchedVendorRow> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] }
- const groupMap: Record<string, ColumnDef<MatchedVendorRow>[]> = {}
-
- vendorRfqColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<MatchedVendorRow> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
-
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "rfqVendorUpdated") {
- const dateVal = cell.getValue() as Date
- if (!dateVal) return null
- return formatDate(dateVal)
- }
-
-
- // code etc...
- return row.getValue(cfg.id) ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- const commentsColumn: ColumnDef<MatchedVendorRow> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // rowAction + openCommentSheet
- setRowAction({ row, type: "comments" })
- openCommentSheet(Number(vendor.id) ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80
- }
-
- const actionsColumn: ColumnDef<MatchedVendorRow> = {
- id: "actions",
- cell: ({ row }) => {
- const rfq = row.original
- const status = row.original.rfqVendorStatus
- const isDisabled = !status || status === 'INVITED' || status === 'ACCEPTED'
-
- if (isDisabled) {
- return (
- <div className="relative group">
- <Button
- aria-label="Actions disabled"
- variant="ghost"
- className="flex size-8 p-0 opacity-50 cursor-not-allowed"
- disabled
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- {/* Tooltip explaining why it's disabled */}
- <div className="absolute hidden group-hover:block right-0 -bottom-8 bg-popover text-popover-foreground text-xs p-2 rounded shadow-md whitespace-nowrap z-50">
- 초대 상태에서는 사용할 수 없습니다
- </div>
- </div>
- )
- }
-
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- {/* 기존 기능: status가 INVITED일 때만 표시 */}
- {(!status || status === 'INVITED') && (
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}>
- 발행하기
- </DropdownMenuItem>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<MatchedVendorRow>[] = []
-
- // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
- // 여기서는 그냥 Object.entries 순서
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹 없음 → 그냥 최상위 레벨 컬럼
- nestedColumns.push(...colDefs)
- } else {
- // 상위 컬럼
- nestedColumns.push({
- id: groupName,
- header: groupName, // "Basic Info", "Metadata" 등
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 4) 최종 컬럼 배열: select, nestedColumns, comments, actions
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- commentsColumn,
- actionsColumn
- ]
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx
deleted file mode 100644
index 9b32cf5f..00000000
--- a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { SelectTrigger } from "@radix-ui/react-select"
-import { type Table } from "@tanstack/react-table"
-import {
- ArrowUp,
- CheckCircle2,
- Download,
- Loader,
- Trash2,
- X,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { Portal } from "@/components/ui/portal"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
-} from "@/components/ui/select"
-import { Separator } from "@/components/ui/separator"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { Kbd } from "@/components/kbd"
-
-import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-import { vendors } from "@/db/schema/vendors"
-import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
-
-interface VendorsTableFloatingBarProps {
- table: Table<MatchedVendorRow>
-}
-
-
-export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
- const rows = table.getFilteredSelectedRowModel().rows
-
- const [isPending, startTransition] = React.useTransition()
- const [action, setAction] = React.useState<
- "update-status" | "export" | "delete"
- >()
- const [popoverOpen, setPopoverOpen] = React.useState(false)
-
- // Clear selection on Escape key press
- React.useEffect(() => {
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- table.toggleAllRowsSelected(false)
- }
- }
-
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [table])
-
-
-
- // 공용 confirm dialog state
- const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
- const [confirmProps, setConfirmProps] = React.useState<{
- title: string
- description?: string
- onConfirm: () => Promise<void> | void
- }>({
- title: "",
- description: "",
- onConfirm: () => { },
- })
-
-
-
-
-
- return (
- <Portal >
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
-
- </div>
- </div>
- </div>
-
-
- {/* 공용 Confirm Dialog */}
- <ActionConfirmDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- title={confirmProps.title}
- description={confirmProps.description}
- onConfirm={confirmProps.onConfirm}
- isLoading={isPending && (action === "delete" || action === "update-status")}
- confirmLabel={
- action === "delete"
- ? "Delete"
- : action === "update-status"
- ? "Update"
- : "Confirm"
- }
- confirmVariant={
- action === "delete" ? "destructive" : "default"
- }
- />
- </Portal>
- )
-}
diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
deleted file mode 100644
index 864d0f4b..00000000
--- a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-
-import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { Button } from "@/components/ui/button"
-import { useToast } from "@/hooks/use-toast"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<MatchedVendorRow>
- rfqId: number
-}
-
-export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
- const { toast } = useToast()
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 선택된 모든 행
- const selectedRows = table.getFilteredSelectedRowModel().rows
-
- // 조건에 맞는 협력업체만 필터링
- const eligibleVendors = React.useMemo(() => {
- return selectedRows
- .map(row => row.original)
- .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED")
- }, [selectedRows])
-
- // 조건에 맞지 않는 협력업체 수
- const ineligibleCount = selectedRows.length - eligibleVendors.length
-
- function handleImportClick() {
- fileInputRef.current?.click()
- }
-
- function handleInviteClick() {
- // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시
- if (ineligibleCount > 0) {
- toast({
- title: "일부 협력업체만 초대됩니다",
- description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`,
- // variant: "warning",
- })
- }
- }
-
- // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시
- const showInviteDialog = eligibleVendors.length > 0
-
- return (
- <div className="flex items-center gap-2">
- {selectedRows.length > 0 && (
- <>
- {showInviteDialog ? (
- <InviteVendorsDialog
- vendors={eligibleVendors}
- rfqId={rfqId}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- onOpenChange={(open) => {
- // 다이얼로그가 열릴 때만 경고 표시
- if (open && ineligibleCount > 0) {
- handleInviteClick()
- }
- }}
- />
- ) : (
- <Button
- variant="default"
- size="sm"
- disabled={true}
- title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다"
- >
- 초대 불가
- </Button>
- )}
- </>
- )}
-
- <AddVendorDialog rfqId={rfqId} />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx
deleted file mode 100644
index b2e4d5ad..00000000
--- a/lib/rfqs/vendor-table/vendors-table.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react" // Next-auth session hook 추가
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./vendors-table-columns"
-import { vendors } from "@/db/schema/vendors"
-import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
-import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
-import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { CommentSheet, MatchedVendorComment } from "./comments-sheet"
-import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
-import { RfqType } from "@/lib/rfqs/validations"
-import { toast } from "sonner"
-
-interface VendorsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]>
- rfqId: number
- rfqType: RfqType
-}
-
-export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
- const { data: session } = useSession() // 세션 정보 가져오기
-
-
-
- // 1) Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환)
-
- console.log(data)
-
- // 2) Row 액션 상태
- const [rowAction, setRowAction] = React.useState<
- DataTableRowAction<MatchedVendorRow> | null
- >(null)
-
- // **router** 획득
- const router = useRouter()
-
- // 3) CommentSheet 에 넣을 상태
- // => "댓글"은 MatchedVendorComment[] 로 관리해야 함
- const [initialComments, setInitialComments] = React.useState<
- MatchedVendorComment[]
- >([])
-
- const [isLoadingComments, setIsLoadingComments] = React.useState(false)
-
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedVendorIdForComments, setSelectedVendorIdForComments] =
- React.useState<number | null>(null)
-
- // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- openCommentSheet(rowAction.row.original.id)
- }
- }, [rowAction])
-
- // 5) 댓글 시트 오픈 함수
- async function openCommentSheet(vendorId: number) {
- // Clear previous comments
- setInitialComments([])
-
- // Start loading
- setIsLoadingComments(true)
-
- // Open the sheet immediately with loading state
- setSelectedVendorIdForComments(vendorId)
- setCommentSheetOpen(true)
-
- // (a) 현재 Row의 comments 불러옴
- const comments = rowAction?.row.original.comments
-
- try {
- if (comments && comments.length > 0) {
- // (b) 각 comment마다 첨부파일 fetch
- const commentWithAttachments: MatchedVendorComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
- return {
- ...c,
- attachments,
- }
- })
- )
- setInitialComments(commentWithAttachments)
- }
- } catch (error) {
- console.error("Error loading comments:", error)
- toast.error("Failed to load comments")
- } finally {
- // End loading regardless of success/failure
- setIsLoadingComments(false)
- }
- }
-
- // 6) 컬럼 정의 (memo)
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet }),
- [setRowAction, router]
- )
-
- // 7) 필터 정의
- const filterFields: DataTableFilterField<MatchedVendorRow>[] = []
-
- const advancedFilterFields: DataTableAdvancedFilterField<MatchedVendorRow>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
- {
- id: "vendorStatus",
- label: "Vendor Status",
- type: "multi-select",
- options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
- value: status,
- })),
- },
- {
- id: "rfqVendorStatus",
- label: "RFQ Status",
- type: "multi-select",
- options: ["INVITED", "ACCEPTED", "REJECTED", "QUOTED"].map((s) => ({
- label: s,
- value: s,
- })),
- },
- { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
- ]
-
- // 8) 테이블 생성
- const { table } = useDataTable({
- data, // MatchedVendorRow[]
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- // 행의 고유 ID
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- // 세션에서 userId 추출하고 숫자로 변환
- const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
-
- return (
- <>
- <DataTable
- table={table}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 초대 다이얼로그 */}
- <InviteVendorsDialog
- vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
- onOpenChange={() => setRowAction(null)}
- rfqId={rfqId}
- open={rowAction?.type === "invite"}
- showTrigger={false}
- rfqType={rfqType}
- />
-
- {/* 댓글 시트 */}
- <CommentSheet
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- initialComments={initialComments}
- rfqId={rfqId}
- vendorId={selectedVendorIdForComments ?? 0}
- currentUserId={currentUserId}
- isLoading={isLoadingComments} // Pass the loading state
- onCommentsUpdated={(updatedComments) => {
- // Row 의 comments 필드도 업데이트
- if (!rowAction?.row) return
- rowAction.row.original.comments = updatedComments
- }}
- />
- </>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/service.ts b/lib/tbe/service.ts
deleted file mode 100644
index e69de29b..00000000
--- a/lib/tbe/service.ts
+++ /dev/null
diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx
deleted file mode 100644
index 35c29d39..00000000
--- a/lib/tbe/table/comments-sheet.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X ,Loader2} from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Textarea,
-} from "@/components/ui/textarea"
-
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput
-} from "@/components/ui/dropzone"
-
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell
-} from "@/components/ui/table"
-
-// DB 스키마에서 필요한 타입들을 가져온다고 가정
-// (실제 프로젝트에 맞춰 import를 수정하세요.)
-import { formatDate } from "@/lib/utils"
-import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
-
-// 코멘트 + 첨부파일 구조 (단순 예시)
-// 실제 DB 스키마에 맞춰 조정
-export interface TbeComment {
- id: number
- commentText: string
- commentedBy?: number
- createdAt?: string | Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- /** 코멘트를 작성할 RFQ 정보 */
- /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
- initialComments?: TbeComment[]
-
- /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
- currentUserId: number
- rfqId:number
- vendorId:number
- isLoading?: boolean // New prop
- /** 댓글 저장 후 갱신용 콜백 (옵션) */
- onCommentsUpdated?: (comments: TbeComment[]) => void
-}
-
-// 새 코멘트 작성 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional() // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- onCommentsUpdated,
- isLoading = false,
- ...props
-}: CommentSheetProps) {
- const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
-
- // RHF 세팅
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: []
- }
- })
-
- // formFieldArray 예시 (파일 목록)
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles"
- })
-
- // 1) 기존 코멘트 + 첨부 보여주기
- // 간단히 테이블 하나로 표현
- // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
-
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {/* 첨부파일 표시 */}
- {(!c.attachments || c.attachments.length === 0) && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments && c.attachments.length > 0 && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={att.filePath}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt, "KR"): "-"}</TableCell>
- <TableCell>
- {c.commentedBy ?? "-"}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // 2) 새 파일 Drop
- function handleDropAccepted(files: File[]) {
- // 드롭된 File[]을 RHF field array에 추가
- const toAppend = files.map((f) => f)
- append(toAppend)
- }
-
-
- // 3) 저장(Submit)
- async function onSubmit(data: CommentFormValues) {
-
- if (!rfqId) return
- startTransition(async () => {
- try {
- // 서버 액션 호출
- const res = await createRfqCommentWithAttachments({
- rfqId: rfqId,
- vendorId: vendorId, // 필요시 세팅
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null, // 필요시 세팅
- files: data.newFiles
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 새 코멘트를 다시 불러오거나,
- // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
- const newComment: TbeComment = {
- id: res.commentId, // 서버에서 반환된 commentId
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: new Date().toISOString(),
- attachments: (data.newFiles?.map((f, idx) => ({
- id: Math.random() * 100000,
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [])
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- // 폼 리셋
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- {/* 기존 코멘트 목록 */}
- <div className="max-h-[300px] overflow-y-auto">
- {renderExistingComments()}
- </div>
-
- {/* 새 코멘트 작성 Form */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter your comment..."
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Dropzone (파일 첨부) */}
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {/* 선택된 파일 목록 */}
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div key={field.id} className="flex items-center justify-between border rounded p-2">
- <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/feature-flags-provider.tsx b/lib/tbe/table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/tbe/table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/tbe/table/file-dialog.tsx b/lib/tbe/table/file-dialog.tsx
deleted file mode 100644
index d22671da..00000000
--- a/lib/tbe/table/file-dialog.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Download, X } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDateTime } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-
-import {
- FileList,
- FileListItem,
- FileListIcon,
- FileListInfo,
- FileListName,
- FileListDescription,
- FileListAction,
-} from "@/components/ui/file-list"
-import { getTbeFilesForVendor } from "@/lib/rfqs/service"
-
-interface TBEFileDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- tbeId: number
- vendorId: number
- rfqId: number
- onRefresh?: () => void
-}
-
-export function TBEFileDialog({
- isOpen,
- onOpenChange,
- vendorId,
- rfqId,
- onRefresh,
-}: TBEFileDialogProps) {
- const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([])
- const [isFetchingFiles, setIsFetchingFiles] = React.useState(false)
-
-
- // Fetch submitted files when dialog opens
- React.useEffect(() => {
- if (isOpen && rfqId && vendorId) {
- fetchSubmittedFiles()
- }
- }, [isOpen, rfqId, vendorId])
-
- // Fetch submitted files using the service function
- const fetchSubmittedFiles = async () => {
- if (!rfqId || !vendorId) return
-
- setIsFetchingFiles(true)
- try {
- const { files, error } = await getTbeFilesForVendor(rfqId, vendorId)
-
- if (error) {
- throw new Error(error)
- }
-
- setSubmittedFiles(files)
- } catch (error) {
- toast.error("Failed to load files: " + getErrorMessage(error))
- } finally {
- setIsFetchingFiles(false)
- }
- }
-
- // Download submitted file
- const downloadSubmittedFile = async (file: any) => {
- try {
- const response = await fetch(`/api/file/${file.id}/download`)
- if (!response.ok) {
- throw new Error("Failed to download file")
- }
-
- const blob = await response.blob()
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = file.fileName
- document.body.appendChild(a)
- a.click()
- window.URL.revokeObjectURL(url)
- document.body.removeChild(a)
- } catch (error) {
- toast.error("Failed to download file: " + getErrorMessage(error))
- }
- }
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-lg">
- <DialogHeader>
- <DialogTitle>TBE 응답 파일</DialogTitle>
- <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription>
- </DialogHeader>
-
- {/* 제출된 파일 목록 */}
- {isFetchingFiles ? (
- <div className="flex justify-center items-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
- </div>
- ) : submittedFiles.length > 0 ? (
- <div className="grid gap-2">
- <FileList>
- {submittedFiles.map((file) => (
- <FileListItem key={file.id} className="flex items-center justify-between gap-3">
- <div className="flex items-center gap-3 flex-1">
- <FileListIcon className="flex-shrink-0" />
- <FileListInfo className="flex-1 min-w-0">
- <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
- <FileListDescription className="text-xs text-muted-foreground">
- {file.uploadedAt ? formatDateTime(file.uploadedAt, "KR") : ""}
- </FileListDescription>
- </FileListInfo>
- </div>
- <FileListAction className="flex-shrink-0 ml-2">
- <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
- <Download className="h-4 w-4" />
- <span className="sr-only">파일 다운로드</span>
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx
deleted file mode 100644
index 59535278..00000000
--- a/lib/tbe/table/invite-vendors-dialog.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Send } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { Input } from "@/components/ui/input"
-
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { inviteTbeVendorsAction } from "@/lib/rfqs/service"
-
-interface InviteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: Row<VendorWithTbeFields>["original"][]
- rfqId: number
- showTrigger?: boolean
- onSuccess?: () => void
- hasMultipleRfqIds?: boolean
-}
-
-export function InviteVendorsDialog({
- vendors,
- rfqId,
- showTrigger = true,
- onSuccess,
- hasMultipleRfqIds,
- ...props
-}: InviteVendorsDialogProps) {
- const [isInvitePending, startInviteTransition] = React.useTransition()
-
-
- // multiple 파일을 받을 state
- const [files, setFiles] = React.useState<FileList | null>(null)
-
- // 미디어쿼리 (desktop 여부)
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onInvite() {
- startInviteTransition(async () => {
- // 파일이 선택되지 않았다면 에러
- if (!files || files.length === 0) {
- toast.error("Please attach TBE files before inviting.")
- return
- }
-
- // FormData 생성
- const formData = new FormData()
- formData.append("rfqId", String(rfqId))
- vendors.forEach((vendor) => {
- formData.append("vendorIds[]", String(vendor.id))
- })
-
- // multiple 파일
- for (let i = 0; i < files.length; i++) {
- formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles"
- }
-
- // 서버 액션 호출
- const { error } = await inviteTbeVendorsAction(formData)
-
- if (error) {
- toast.error(error)
- return
- }
-
- // 성공
- props.onOpenChange?.(false)
- toast.success("Vendors invited with TBE!")
- onSuccess?.()
- })
- }
-
- // 파일 선택 UI
- const fileInput = (
- <div className="mb-4">
- <label className="mb-2 block font-medium">TBE Sheets</label>
- <Input
- type="file"
- multiple
- onChange={(e) => {
- setFiles(e.target.files)
- }}
- />
- </div>
- )
- if (hasMultipleRfqIds) {
- toast.error("동일한 RFQ에 대해 선택해주세요");
- return;
- }
- // Desktop Dialog
- if (isDesktop) {
- return (
-
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Are you absolutely sure?</DialogTitle>
- <DialogDescription>
- This action cannot be undone. This will permanently invite{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 파일 첨부 */}
- {fileInput}
-
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">Cancel</Button>
- </DialogClose>
- <Button
- aria-label="Invite selected rows"
- variant="destructive"
- onClick={onInvite}
- // 파일이 없거나 초대 진행중이면 비활성화
- disabled={isInvitePending || !files || files.length === 0}
- >
- {isInvitePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- Invite
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- // Mobile Drawer
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Send className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>Are you absolutely sure?</DrawerTitle>
- <DrawerDescription>
- This action cannot be undone. This will permanently invite{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}.
- </DrawerDescription>
- </DrawerHeader>
-
- {/* 파일 첨부 */}
- {fileInput}
-
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
- </DrawerClose>
- <Button
- aria-label="Invite selected rows"
- variant="destructive"
- onClick={onInvite}
- // 파일이 없거나 초대 진행중이면 비활성화
- disabled={isInvitePending || !files || files.length === 0}
- >
- {isInvitePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- Invite
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-result-dialog.tsx b/lib/tbe/table/tbe-result-dialog.tsx
deleted file mode 100644
index 59e2f49b..00000000
--- a/lib/tbe/table/tbe-result-dialog.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { getErrorMessage } from "@/lib/handle-error"
-import { saveTbeResult } from "@/lib/rfqs/service"
-
-// Define the props for the TbeResultDialog component
-interface TbeResultDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- tbe: VendorWithTbeFields | null
- onRefresh?: () => void
-}
-
-// Define TBE result options
-const TBE_RESULT_OPTIONS = [
- { value: "pass", label: "Pass", badgeVariant: "default" },
- { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" },
- { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" },
-] as const
-
-type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"]
-
-export function TbeResultDialog({
- open,
- onOpenChange,
- tbe,
- onRefresh,
-}: TbeResultDialogProps) {
- // Initialize state for form inputs
- const [result, setResult] = React.useState<TbeResultOption | "">("")
- const [note, setNote] = React.useState("")
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // Update form values when the tbe prop changes
- React.useEffect(() => {
- if (tbe) {
- setResult((tbe.tbeResult as TbeResultOption) || "")
- setNote(tbe.tbeNote || "")
- }
- }, [tbe])
-
- // Reset form when dialog closes
- React.useEffect(() => {
- if (!open) {
- // Small delay to avoid visual glitches when dialog is closing
- const timer = setTimeout(() => {
- if (!tbe) {
- setResult("")
- setNote("")
- }
- }, 300)
- return () => clearTimeout(timer)
- }
- }, [open, tbe])
-
- // Handle form submission with server action
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
-
- if (!tbe || !result) return
-
- setIsSubmitting(true)
-
- try {
- // Call the server action to save the TBE result
- const response = await saveTbeResult({
- id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table
- vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table
- result: result, // The selected evaluation result
- notes: note, // The evaluation notes
- })
-
- if (!response.success) {
- throw new Error(response.message || "Failed to save TBE result")
- }
-
- // Show success toast
- toast.success("TBE result saved successfully")
-
- // Close the dialog
- onOpenChange(false)
-
- // Refresh the data if refresh callback is provided
- if (onRefresh) {
- onRefresh()
- }
- } catch (error) {
- // Show error toast
- toast.error(`Failed to save: ${getErrorMessage(error)}`)
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // Find the selected result option
- const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result)
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle className="text-xl font-semibold">
- {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"}
- </DialogTitle>
- {tbe && (
- <DialogDescription className="text-sm text-muted-foreground mt-1">
- <div className="flex flex-col gap-1">
- <span>
- <strong>Vendor:</strong> {tbe.vendorName}
- </span>
- <span>
- <strong>RFQ Code:</strong> {tbe.rfqCode}
- </span>
- {tbe.email && (
- <span>
- <strong>Email:</strong> {tbe.email}
- </span>
- )}
- </div>
- </DialogDescription>
- )}
- </DialogHeader>
-
- <form onSubmit={handleSubmit} className="space-y-6 py-2">
- <div className="space-y-2">
- <Label htmlFor="tbe-result" className="text-sm font-medium">
- Evaluation Result
- </Label>
- <Select
- value={result}
- onValueChange={(value) => setResult(value as TbeResultOption)}
- required
- >
- <SelectTrigger id="tbe-result" className="w-full">
- <SelectValue placeholder="Select a result" />
- </SelectTrigger>
- <SelectContent>
- {TBE_RESULT_OPTIONS.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- <div className="flex items-center">
- <Badge variant={option.badgeVariant as any} className="mr-2">
- {option.label}
- </Badge>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="tbe-note" className="text-sm font-medium">
- Evaluation Note
- </Label>
- <Textarea
- id="tbe-note"
- placeholder="Enter evaluation notes..."
- value={note}
- onChange={(e) => setNote(e.target.value)}
- className="min-h-[120px] resize-y"
- />
- </div>
-
- <DialogFooter className="gap-2 sm:gap-0">
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={!result || isSubmitting}
- className="min-w-[100px]"
- >
- {isSubmitting ? "Saving..." : "Save"}
- </Button>
- </DialogFooter>
- </form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx
deleted file mode 100644
index f30cd0e0..00000000
--- a/lib/tbe/table/tbe-table-columns.tsx
+++ /dev/null
@@ -1,344 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Ellipsis, MessageSquare } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-import {
- VendorTbeColumnConfig,
- vendorTbeColumnsConfig,
- VendorWithTbeFields,
-} from "@/config/vendorTbeColumnsConfig"
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>>
- router: NextRouter
- openCommentSheet: (vendorId: number, rfqId: number) => void
- openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void
- openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처
-
-}
-
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- router,
- openCommentSheet,
- openFilesDialog,
- openVendorContactsDialog
-}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorWithTbeFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {}
-
- vendorTbeColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<VendorWithTbeFields>
- const childCol: ColumnDef<VendorWithTbeFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
- if (cfg.id === "vendorName") {
- const vendor = row.original;
- const vendorId = vendor.vendorId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (vendorId) {
- openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
- if (cfg.id === "tbeResult") {
- const vendor = row.original;
- const tbeResult = vendor.tbeResult;
- const filesCount = vendor.files?.length ?? 0;
-
- // Only show button or link if there are files
- if (filesCount > 0) {
- // Function to handle clicking on the result
- const handleTbeResultClick = () => {
- setRowAction({ row, type: "tbeResult" });
- };
-
- if (!tbeResult) {
- // No result yet, but files exist - show "결과 입력" button
- return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleTbeResultClick}
- >
- 결과 입력
- </Button>
- );
- } else {
- // Result exists - show as a hyperlink
- let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline";
-
- // Set badge variant based on result
- if (tbeResult === "pass") {
- badgeVariant = "default";
- } else if (tbeResult === "non-pass") {
- badgeVariant = "destructive";
- } else if (tbeResult === "conditional pass") {
- badgeVariant = "secondary";
- }
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto underline"
- onClick={handleTbeResultClick}
- >
- <Badge variant={badgeVariant}>
- {tbeResult}
- </Badge>
- </Button>
- );
- }
- }
-
- // No files available, return empty cell
- return null;
- }
-
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
- // 예) TBE Updated (날짜)
- if (cfg.id === "tbeUpdated") {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal, "KR")
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<VendorWithTbeFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-// 파일 칼럼
-const filesColumn: ColumnDef<VendorWithTbeFields> = {
- id: "files",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Response Files" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const filesCount = vendor.files?.length ?? 0
-
- function handleClick() {
- // setRowAction으로 타입만 설정하고 끝내는 방법도 가능하지만
- // 혹은 바로 openFilesDialog()를 호출해도 됨.
- setRowAction({ row, type: "files" })
- // 필요한 값을 직접 호출해서 넘겨줄 수도 있음.
- openFilesDialog(
- vendor.tbeId ?? 0,
- vendor.vendorId ?? 0,
- vendor.rfqId ?? 0,
- )
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"}
- >
- <Download className="h-4 w-4" />
- {filesCount > 0 && (
- <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center">
- {filesCount}
- </Badge>
- )}
- </Button>
- )
- },
- enableSorting: false,
- minSize: 80,
-}
-
-// 댓글 칼럼
-const commentsColumn: ColumnDef<VendorWithTbeFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // setRowAction() 로 type 설정
- setRowAction({ row, type: "comments" })
- // 필요하면 즉시 openCommentSheet() 직접 호출
- openCommentSheet(
- vendor.vendorId ?? 0,
- vendor.rfqId ?? 0,
- )
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- minSize: 80,
-}
-// ----------------------------------------------------------------
-// 5) 최종 컬럼 배열 - Update to include the files column
-// ----------------------------------------------------------------
-return [
- selectColumn,
- ...nestedColumns,
- filesColumn, // Add the files column before comments
- commentsColumn,
- // actionsColumn,
-]
-
-} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx
deleted file mode 100644
index cf6a041e..00000000
--- a/lib/tbe/table/tbe-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-
-
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<VendorWithTbeFields>
- rfqId: number
-}
-
-export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일이 선택되었을 때 처리
-
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
- }
-
- // 선택된 행이 있는 경우 rfqId 확인
- const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0
- ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))]
- : [];
-
- const hasMultipleRfqIds = uniqueRfqIds.length > 1;
-
- const invitationPossibeVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original)
- .filter(vendor => vendor.technicalResponseStatus === null);
- }, [table.getFilteredSelectedRowModel().rows]);
-
- return (
- <div className="flex items-center gap-2">
- {invitationPossibeVendors.length > 0 && (
- <InviteVendorsDialog
- vendors={invitationPossibeVendors}
- rfqId={rfqId}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- hasMultipleRfqIds={hasMultipleRfqIds}
- />
- )}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx
deleted file mode 100644
index 83d601b3..00000000
--- a/lib/tbe/table/tbe-table.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./tbe-table-columns"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { CommentSheet, TbeComment } from "./comments-sheet"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { TBEFileDialog } from "./file-dialog"
-import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service"
-import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions"
-import { TbeResultDialog } from "./tbe-result-dialog"
-import { toast } from "sonner"
-import { VendorContactsDialog } from "./vendor-contact-dialog"
-
-interface VendorsTableProps {
- promises: Promise<[
- Awaited<ReturnType<typeof getAllTBE>>,
- ]>
-}
-
-export function AllTbeTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
- const router = useRouter()
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
-
- // 댓글 시트 관련 state
- const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
- const [isLoadingComments, setIsLoadingComments] = React.useState(false)
-
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- // 파일 다이얼로그 관련 state
- const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
- const [selectedVendorIdForFiles, setSelectedVendorIdForFiles] = React.useState<number | null>(null)
- const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null)
- const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null)
-
- const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
- const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null)
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
-
- // 테이블 리프레시용
- const handleRefresh = React.useCallback(() => {
- router.refresh();
- }, [router]);
-
- // -----------------------------------------------------------
- // 특정 action이 설정될 때마다 실행되는 effect
- // -----------------------------------------------------------
- React.useEffect(() => {
- if (!rowAction) return
-
- if (rowAction.type === "comments") {
- // rowAction가 새로 세팅되면 openCommentSheet 실행
- // row.original에 rfqId가 있다고 가정
- openCommentSheet(
- rowAction.row.original.vendorId ?? 0,
- rowAction.row.original.rfqId ?? 0,
- )
- } else if (rowAction.type === "files") {
- openFilesDialog(
- rowAction.row.original.tbeId ?? 0,
- rowAction.row.original.vendorId ?? 0,
- rowAction.row.original.rfqId ?? 0,
- )
- }
- }, [rowAction])
-
- // -----------------------------------------------------------
- // 댓글 시트 열기
- // -----------------------------------------------------------
- async function openCommentSheet(vendorId: number, rfqId: number) {
- setInitialComments([])
- setIsLoadingComments(true)
- const comments = rowAction?.row.original.comments
- try {
- if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
- return {
- ...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
- setInitialComments(commentWithAttachments)
- }
-
- setSelectedVendorIdForComments(vendorId)
- setSelectedRfqIdForComments(rfqId)
- setCommentSheetOpen(true)
- } catch (error) {
- console.error("Error loading comments:", error)
- toast.error("Failed to load comments")
- } finally {
- // End loading regardless of success/failure
- setIsLoadingComments(false)
- }
- }
-
- // -----------------------------------------------------------
- // 파일 다이얼로그 열기
- // -----------------------------------------------------------
- const openFilesDialog = (tbeId: number, vendorId: number, rfqId: number) => {
- setSelectedTbeIdForFiles(tbeId)
- setSelectedVendorIdForFiles(vendorId)
- setSelectedRfqIdForFiles(rfqId)
- setIsFileDialogOpen(true)
- }
-
- const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => {
- setSelectedVendorId(vendorId)
- setSelectedVendor(vendor)
- setIsContactDialogOpen(true)
- }
-
-
- // -----------------------------------------------------------
- // 테이블 컬럼
- // -----------------------------------------------------------
- const columns = React.useMemo(
- () =>
- getColumns({
- setRowAction,
- router,
- openCommentSheet, // 필요하면 직접 호출 가능
- openFilesDialog,
- openVendorContactsDialog,
- }),
- [setRowAction, router]
- )
-
- // -----------------------------------------------------------
- // 필터 필드
- // -----------------------------------------------------------
- const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [
- // 예: 표준 필터
- ]
- const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
- {
- id: "vendorStatus",
- label: "Vendor Status",
- type: "multi-select",
- options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
- value: status,
- })),
- },
- { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
- ]
-
- // -----------------------------------------------------------
- // 테이블 생성 훅
- // -----------------------------------------------------------
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["files", "comments"] },
- },
- getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 댓글 시트 */}
- <CommentSheet
- currentUserId={1}
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- vendorId={selectedVendorIdForComments ?? 0}
- rfqId={selectedRfqIdForComments ?? 0}
- isLoading={isLoadingComments}
- initialComments={initialComments}
- />
-
- {/* 파일 업로드/다운로드 다이얼로그 */}
- <TBEFileDialog
- isOpen={isFileDialogOpen}
- onOpenChange={setIsFileDialogOpen}
- tbeId={selectedTbeIdForFiles ?? 0}
- vendorId={selectedVendorIdForFiles ?? 0}
- rfqId={selectedRfqIdForFiles ?? 0} // ← 여기!
- onRefresh={handleRefresh}
- />
-
- <TbeResultDialog
- open={rowAction?.type === "tbeResult"}
- onOpenChange={() => setRowAction(null)}
- tbe={rowAction?.row.original ?? null}
- />
-
- <VendorContactsDialog
- isOpen={isContactDialogOpen}
- onOpenChange={setIsContactDialogOpen}
- vendorId={selectedVendorId}
- vendor={selectedVendor}
- />
-
- </>
- )
-} \ No newline at end of file
diff --git a/lib/tbe/table/vendor-contact-dialog.tsx b/lib/tbe/table/vendor-contact-dialog.tsx
deleted file mode 100644
index 6c96d2ef..00000000
--- a/lib/tbe/table/vendor-contact-dialog.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
-import { VendorContactsTable } from "@/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table"
-
-interface VendorContactsDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- vendorId: number | null
- vendor: VendorWithTbeFields | null
-}
-
-export function VendorContactsDialog({
- isOpen,
- onOpenChange,
- vendorId,
- vendor,
-}: VendorContactsDialogProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
- <DialogHeader>
- <div className="flex flex-col space-y-2">
- <DialogTitle>협력업체 연락처</DialogTitle>
- {vendor && (
- <div className="flex flex-col space-y-1 mt-2">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium text-foreground">{vendor.vendorName}</span>
- {vendor.vendorCode && (
- <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
- )}
- </div>
- <div className="flex items-center">
- {vendor.vendorStatus && (
- <Badge variant="outline" className="mr-2">
- {vendor.vendorStatus}
- </Badge>
- )}
- {vendor.rfqVendorStatus && (
- <Badge
- variant={
- vendor.rfqVendorStatus === "INVITED" ? "default" :
- vendor.rfqVendorStatus === "DECLINED" ? "destructive" :
- vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline"
- }
- >
- {vendor.rfqVendorStatus}
- </Badge>
- )}
- </div>
- </div>
- )}
- </div>
- </DialogHeader>
- {vendorId && (
- <div className="py-4">
- <VendorContactsTable vendorId={vendorId} />
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts
deleted file mode 100644
index 8f2954d7..00000000
--- a/lib/vendor-rfq-response/service.ts
+++ /dev/null
@@ -1,464 +0,0 @@
-'use server'
-
-import { revalidateTag, unstable_cache } from "next/cache";
-import db from "@/db/db";
-import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
-import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq";
-import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq";
-import { items } from "@/db/schema/items";
-import { GetRfqsForVendorsSchema } from "../rfqs/validations";
-import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
-import * as z from "zod"
-
-
-
-export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) {
- return unstable_cache(
- async () => {
- const offset = (input.page - 1) * input.perPage;
- const limit = input.perPage;
-
- // 1) 메인 쿼리: vendorResponsesView 사용
- const { rows, total } = await db.transaction(async (tx) => {
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponsesView.projectName} ILIKE ${s}`,
- sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
- );
- }
-
- // 협력업체 ID 필터링
- const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
-
- // 정렬: 응답 시간순
- const orderBy = [desc(vendorResponsesView.respondedAt)];
-
- // (A) 데이터 조회
- const data = await tx
- .select()
- .from(vendorResponsesView)
- .where(mainWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- // (B) 전체 개수 카운트
- const [{ count }] = await tx
- .select({
- count: sql<number>`count(*)`.as("count"),
- })
- .from(vendorResponsesView)
- .where(mainWhere);
-
- return { rows: data, total: Number(count) };
- });
-
- // 2) rfqId 고유 목록 추출
- const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))];
- if (distinctRfqs.length === 0) {
- return { data: [], pageCount: 0 };
- }
-
- // 3) 추가 데이터 조회
- // 3-A) RFQ 아이템
- const itemsAll = await db
- .select({
- id: rfqItems.id,
- rfqId: rfqItems.rfqId,
- itemCode: rfqItems.itemCode,
- itemName: items.itemName,
- quantity: rfqItems.quantity,
- description: rfqItems.description,
- uom: rfqItems.uom,
- })
- .from(rfqItems)
- .leftJoin(items, eq(rfqItems.itemCode, items.itemCode))
- .where(inArray(rfqItems.rfqId, distinctRfqs));
-
- // 3-B) RFQ 첨부 파일 (협력업체용)
- const attachAll = await db
- .select()
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.rfqId, distinctRfqs),
- isNull(rfqAttachments.vendorId)
- )
- );
-
- // 3-C) RFQ 코멘트
- const commAll = await db
- .select()
- .from(rfqComments)
- .where(
- and(
- inArray(rfqComments.rfqId, distinctRfqs),
- or(
- isNull(rfqComments.vendorId),
- eq(rfqComments.vendorId, vendorId)
- )
- )
- );
-
-
- // 3-E) 협력업체 응답 상세 - 기술
- const technicalResponsesAll = await db
- .select()
- .from(vendorTechnicalResponses)
- .where(
- inArray(
- vendorTechnicalResponses.responseId,
- rows.map((r) => r.responseId)
- )
- );
-
- // 3-F) 협력업체 응답 상세 - 상업
- const commercialResponsesAll = await db
- .select()
- .from(vendorCommercialResponses)
- .where(
- inArray(
- vendorCommercialResponses.responseId,
- rows.map((r) => r.responseId)
- )
- );
-
- // 3-G) 협력업체 응답 첨부 파일
- const responseAttachmentsAll = await db
- .select()
- .from(vendorResponseAttachments)
- .where(
- inArray(
- vendorResponseAttachments.responseId,
- rows.map((r) => r.responseId)
- )
- );
-
- // 4) 데이터 그룹화
- // RFQ 아이템 그룹화
- const itemsByRfqId = new Map<number, any[]>();
- for (const it of itemsAll) {
- if (!itemsByRfqId.has(it.rfqId)) {
- itemsByRfqId.set(it.rfqId, []);
- }
- itemsByRfqId.get(it.rfqId)!.push({
- id: it.id,
- itemCode: it.itemCode,
- itemName: it.itemName,
- quantity: it.quantity,
- description: it.description,
- uom: it.uom,
- });
- }
-
- // RFQ 첨부 파일 그룹화
- const attachByRfqId = new Map<number, any[]>();
- for (const att of attachAll) {
- const rid = att.rfqId!;
- if (!attachByRfqId.has(rid)) {
- attachByRfqId.set(rid, []);
- }
- attachByRfqId.get(rid)!.push({
- id: att.id,
- fileName: att.fileName,
- filePath: att.filePath,
- vendorId: att.vendorId,
- evaluationId: att.evaluationId,
- });
- }
-
- // RFQ 코멘트 그룹화
- const commByRfqId = new Map<number, any[]>();
- for (const c of commAll) {
- const rid = c.rfqId!;
- if (!commByRfqId.has(rid)) {
- commByRfqId.set(rid, []);
- }
- commByRfqId.get(rid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- });
- }
-
-
- // 기술 응답 그룹화
- const techResponseByResponseId = new Map<number, any>();
- for (const tr of technicalResponsesAll) {
- techResponseByResponseId.set(tr.responseId, {
- id: tr.id,
- summary: tr.summary,
- notes: tr.notes,
- createdAt: tr.createdAt,
- updatedAt: tr.updatedAt,
- });
- }
-
- // 상업 응답 그룹화
- const commResponseByResponseId = new Map<number, any>();
- for (const cr of commercialResponsesAll) {
- commResponseByResponseId.set(cr.responseId, {
- id: cr.id,
- totalPrice: cr.totalPrice,
- currency: cr.currency,
- paymentTerms: cr.paymentTerms,
- incoterms: cr.incoterms,
- deliveryPeriod: cr.deliveryPeriod,
- warrantyPeriod: cr.warrantyPeriod,
- validityPeriod: cr.validityPeriod,
- priceBreakdown: cr.priceBreakdown,
- commercialNotes: cr.commercialNotes,
- createdAt: cr.createdAt,
- updatedAt: cr.updatedAt,
- });
- }
-
- // 응답 첨부 파일 그룹화
- const respAttachByResponseId = new Map<number, any[]>();
- for (const ra of responseAttachmentsAll) {
- const rid = ra.responseId!;
- if (!respAttachByResponseId.has(rid)) {
- respAttachByResponseId.set(rid, []);
- }
- respAttachByResponseId.get(rid)!.push({
- id: ra.id,
- fileName: ra.fileName,
- filePath: ra.filePath,
- attachmentType: ra.attachmentType,
- description: ra.description,
- uploadedAt: ra.uploadedAt,
- uploadedBy: ra.uploadedBy,
- });
- }
-
- // 5) 최종 데이터 결합
- const final = rows.map((row) => {
- return {
- // 응답 정보
- responseId: row.responseId,
- responseStatus: row.responseStatus,
- respondedAt: row.respondedAt,
-
- // RFQ 기본 정보
- rfqId: row.rfqId,
- rfqCode: row.rfqCode,
- rfqDescription: row.rfqDescription,
- rfqDueDate: row.rfqDueDate,
- rfqStatus: row.rfqStatus,
- rfqType: row.rfqType,
- rfqCreatedAt: row.rfqCreatedAt,
- rfqUpdatedAt: row.rfqUpdatedAt,
- rfqCreatedBy: row.rfqCreatedBy,
-
- // 프로젝트 정보
- projectId: row.projectId,
- projectCode: row.projectCode,
- projectName: row.projectName,
-
- // 협력업체 정보
- vendorId: row.vendorId,
- vendorName: row.vendorName,
- vendorCode: row.vendorCode,
-
- // RFQ 관련 데이터
- items: itemsByRfqId.get(row.rfqId) || [],
- attachments: attachByRfqId.get(row.rfqId) || [],
- comments: commByRfqId.get(row.rfqId) || [],
-
- // 평가 정보
- tbeEvaluation: row.tbeId ? {
- id: row.tbeId,
- result: row.tbeResult,
- } : null,
- cbeEvaluation: row.cbeId ? {
- id: row.cbeId,
- result: row.cbeResult,
- } : null,
-
- // 협력업체 응답 상세
- technicalResponse: techResponseByResponseId.get(row.responseId) || null,
- commercialResponse: commResponseByResponseId.get(row.responseId) || null,
- responseAttachments: respAttachByResponseId.get(row.responseId) || [],
-
- // 응답 상태 표시
- hasTechnicalResponse: row.hasTechnicalResponse,
- hasCommercialResponse: row.hasCommercialResponse,
- attachmentCount: row.attachmentCount || 0,
- };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
- return { data: final, pageCount };
- },
- [JSON.stringify(input), `${vendorId}`],
- {
- revalidate: 600,
- tags: ["rfqs-vendor", `vendor-${vendorId}`],
- }
- )();
-}
-
-
-export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> {
- try {
- if (!rfqId || isNaN(Number(rfqId))) {
- return {
- success: false,
- error: "Invalid RFQ ID provided",
- }
- }
-
- // Query the database to get all items for the given RFQ ID
- const items = await db
- .select()
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
- .orderBy(rfqItems.itemCode)
-
-
- return {
- success: true,
- data: items as ItemData[],
- }
- } catch (error) {
- console.error("Error fetching RFQ items:", error)
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items",
- }
- }
-}
-
-
-// Define the schema for validation
-const commercialResponseSchema = z.object({
- responseId: z.number(),
- vendorId: z.number(), // Added vendorId field
- responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
- totalPrice: z.number().optional(),
- currency: z.string().default("USD"),
- paymentTerms: z.string().optional(),
- incoterms: z.string().optional(),
- deliveryPeriod: z.string().optional(),
- warrantyPeriod: z.string().optional(),
- validityPeriod: z.string().optional(),
- priceBreakdown: z.string().optional(),
- commercialNotes: z.string().optional(),
-})
-
-type CommercialResponseInput = z.infer<typeof commercialResponseSchema>
-
-interface ResponseType {
- success: boolean
- error?: string
- data?: any
-}
-
-export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> {
- try {
- // Validate input data
- const validated = commercialResponseSchema.parse(input)
-
- // Check if a commercial response already exists for this responseId
- const existingResponse = await db
- .select()
- .from(vendorCommercialResponses)
- .where(eq(vendorCommercialResponses.responseId, validated.responseId))
- .limit(1)
-
- const now = new Date()
-
- if (existingResponse.length > 0) {
- // Update existing record
- await db
- .update(vendorCommercialResponses)
- .set({
- responseStatus: validated.responseStatus,
- totalPrice: validated.totalPrice,
- currency: validated.currency,
- paymentTerms: validated.paymentTerms,
- incoterms: validated.incoterms,
- deliveryPeriod: validated.deliveryPeriod,
- warrantyPeriod: validated.warrantyPeriod,
- validityPeriod: validated.validityPeriod,
- priceBreakdown: validated.priceBreakdown,
- commercialNotes: validated.commercialNotes,
- updatedAt: now,
- })
- .where(eq(vendorCommercialResponses.responseId, validated.responseId))
-
- } else {
- // Return error instead of creating a new record
- return {
- success: false,
- error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다."
- }
- }
-
- // Also update the main vendor response status if submitted
- if (validated.responseStatus === "SUBMITTED") {
- // Get the vendor response
- const vendorResponseResult = await db
- .select()
- .from(vendorResponses)
- .where(eq(vendorResponses.id, validated.responseId))
- .limit(1)
-
- if (vendorResponseResult.length > 0) {
- // Update the main response status to RESPONDED
- await db
- .update(vendorResponses)
- .set({
- responseStatus: "RESPONDED",
- updatedAt: now,
- })
- .where(eq(vendorResponses.id, validated.responseId))
- }
- }
-
- // Use vendorId for revalidateTag
- revalidateTag(`cbe-vendor-${validated.vendorId}`)
-
- return {
- success: true,
- data: { responseId: validated.responseId }
- }
-
- } catch (error) {
- console.error("Error updating commercial response:", error)
-
- if (error instanceof z.ZodError) {
- return {
- success: false,
- error: "유효하지 않은 데이터가 제공되었습니다."
- }
- }
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown error occurred"
- }
- }
-}
-// Helper function to get responseId from rfqId and vendorId
-export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> {
- try {
- const response = await db
- .select()
- .from(vendorCommercialResponses)
- .where(eq(vendorCommercialResponses.responseId, responseId))
- .limit(1)
-
- return response.length > 0 ? response[0] : null
- } catch (error) {
- console.error("Error getting commercial response:", error)
- return null
- }
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts
deleted file mode 100644
index 3f595ebb..00000000
--- a/lib/vendor-rfq-response/types.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-// RFQ 아이템 타입
-export interface RfqResponseItem {
- id: number;
- itemCode: string;
- itemName: string;
- quantity?: number;
- uom?: string;
- description?: string | null;
-}
-
-// RFQ 첨부 파일 타입
-export interface RfqResponseAttachment {
- id: number;
- fileName: string;
- filePath: string;
- vendorId?: number | null;
- evaluationId?: number | null;
-}
-
-// RFQ 코멘트 타입
-export interface RfqResponseComment {
- id: number;
- commentText: string;
- vendorId?: number | null;
- evaluationId?: number | null;
- createdAt: Date;
- commentedBy?: number;
-}
-
-// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화
-export interface RfqResponse {
- // 응답 정보
- responseId: number;
- responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED";
- respondedAt: Date;
-
- // RFQ 기본 정보
- rfqId: number;
- rfqCode: string;
- rfqDescription?: string | null;
- rfqDueDate?: Date | null;
- rfqStatus: string;
- rfqType?: string | null;
- rfqCreatedAt: Date;
- rfqUpdatedAt: Date;
- rfqCreatedBy?: number | null;
-
- // 프로젝트 정보
- projectId?: number | null;
- projectCode?: string | null;
- projectName?: string | null;
-
- // 협력업체 정보
- vendorId: number;
- vendorName: string;
- vendorCode?: string | null;
-
- // RFQ 관련 데이터
- items: RfqResponseItem[];
- attachments: RfqResponseAttachment[];
- comments: RfqResponseComment[];
-}
-
-// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입
-export interface RfqResponseWithId extends RfqResponse {
- id: number; // rfqId와 동일하게 사용
-}
-
-// 페이지네이션 결과 타입
-export interface RfqResponsesResult {
- data: RfqResponseWithId[];
- pageCount: number;
-}
-
-// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지)
-export type RfqWithAll = RfqResponseWithId; \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
deleted file mode 100644
index c7be0bf4..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
+++ /dev/null
@@ -1,365 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
-import { toast } from "sonner"
-
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
- >
- router: NextRouter
- openCommentSheet: (vendorId: number) => void
- handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void
- loadingVendors: Record<string, boolean>
- openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void
- // New prop for handling commercial response
- openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- router,
- openCommentSheet,
- handleDownloadCbeFiles,
- loadingVendors,
- openVendorContactsDialog,
- openCommercialResponseSheet
-}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorWithCbeFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
-
- vendorResponseCbeColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<VendorWithCbeFields>
- const childCol: ColumnDef<VendorWithCbeFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- maxSize: 120,
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
-
- if (cfg.id === "rfqCode") {
- const rfq = row.original;
- const rfqId = rfq.rfqId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (rfqId) {
- openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
-
- // Commercial Response Status에 배지 적용
- if (cfg.id === "commercialResponseStatus") {
- const status = val as string;
-
- if (!status) return <span className="text-muted-foreground">-</span>;
-
- let variant: "default" | "outline" | "secondary" | "destructive" = "outline";
-
- switch (status) {
- case "SUBMITTED":
- variant = "default"; // Green
- break;
- case "IN_PROGRESS":
- variant = "secondary"; // Orange/Yellow
- break;
- case "PENDING":
- variant = "outline"; // Gray
- break;
- default:
- variant = "outline";
- }
-
- return (
- <Badge variant={variant} className="capitalize">
- {status.toLowerCase().replace("_", " ")}
- </Badge>
- );
- }
-
- // 예) TBE Updated (날짜)
- if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal)
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 3) Respond 컬럼 (새로 추가)
- // ----------------------------------------------------------------
- const respondColumn: ColumnDef<VendorWithCbeFields> = {
- id: "respond",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Response" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const responseId = vendor.responseId
-
- if (!responseId) {
- return <div className="text-center text-muted-foreground">-</div>
- }
-
- const handleClick = () => {
- openCommercialResponseSheet(responseId, vendor)
- }
-
- // Status에 따라 버튼 variant 변경
- let variant: "default" | "outline" | "ghost" | "secondary" = "default"
- let buttonText = "Respond"
-
- if (vendor.commercialResponseStatus === "SUBMITTED") {
- variant = "outline"
- buttonText = "Update"
- } else if (vendor.commercialResponseStatus === "IN_PROGRESS") {
- variant = "secondary"
- buttonText = "Continue"
- }
-
- return (
- <Button
- variant={variant}
- size="sm"
- // className="w-20"
- onClick={handleClick}
- >
- <FileEdit className="h-3.5 w-3.5 mr-1" />
- {buttonText}
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 200,
- minSize: 115,
- }
-
- // ----------------------------------------------------------------
- // 4) Comments 컬럼
- // ----------------------------------------------------------------
- const commentsColumn: ColumnDef<VendorWithCbeFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // rowAction + openCommentSheet
- setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.responseId ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80
- }
-
- // ----------------------------------------------------------------
- // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용)
- // ----------------------------------------------------------------
- const downloadColumn: ColumnDef<VendorWithCbeFields> = {
- id: "attachDownload",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Attach Download" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const vendorId = vendor.vendorId
- const rfqId = vendor.rfqId
- const files = vendor.files?.length || 0
-
- if (!vendorId || !rfqId) {
- return <div className="text-center text-muted-foreground">-</div>
- }
-
- // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용)
- const rowKey = `${vendorId}_${rfqId}`
- const isRowLoading = loadingVendors[rowKey] === true
-
- // 템플릿 파일이 없으면 다운로드 버튼 비활성화
- const isDisabled = files <= 0 || isRowLoading
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={
- isDisabled
- ? undefined
- : () => handleDownloadCbeFiles(vendorId, rfqId)
- }
- aria-label={
- isRowLoading
- ? "다운로드 중..."
- : files > 0
- ? `CBE 첨부 다운로드 (${files}개)`
- : "다운로드할 파일 없음"
- }
- disabled={isDisabled}
- >
- {isRowLoading ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- )}
-
- {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */}
- {!isRowLoading && files > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {files}
- </Badge>
- )}
-
- <span className="sr-only">
- {isRowLoading
- ? "다운로드 중..."
- : files > 0
- ? `CBE 첨부 다운로드 (${files}개)`
- : "다운로드할 파일 없음"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80,
- }
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열 (respondColumn 추가)
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- respondColumn, // 응답 컬럼 추가
- downloadColumn,
- commentsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
deleted file mode 100644
index 8477f550..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getColumns } from "./cbe-table-columns"
-import {
- fetchRfqAttachmentsbyCommentId,
- getCBEbyVendorId,
- getFileFromRfqAttachmentsbyid,
- fetchCbeFiles
-} from "../../rfqs/service"
-import { useSession } from "next-auth/react"
-import { CbeComment, CommentSheet } from "./comments-sheet"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { toast } from "sonner"
-import { RfqDeailDialog } from "./rfq-detail-dialog"
-import { CommercialResponseSheet } from "./respond-cbe-sheet"
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getCBEbyVendorId>>,
- ]
- >
-}
-
-export function CbeVendorTable({ promises }: VendorsTableProps) {
- const { data: session } = useSession()
- const userVendorId = session?.user?.companyId
- const userId = Number(session?.user?.id)
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
- const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
-
- // 개별 협력업체별 로딩 상태를 관리하는 맵
- const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({})
-
- const router = useRouter()
-
- // 코멘트 관련 상태
- const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- // 상업 응답 관련 상태
- const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false)
- const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null)
- const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null)
-
- // RFQ 상세 관련 상태
- const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false)
- const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
- const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null)
-
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.responseId))
- }
- }, [rowAction])
-
- async function openCommentSheet(responseId: number) {
- setInitialComments([])
-
- const comments = rowAction?.row.original.comments
- const rfqId = rowAction?.row.original.rfqId
-
- if (comments && comments.length > 0) {
- const commentWithAttachments: CbeComment[] = await Promise.all(
- comments.map(async (c) => {
- // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
-
- return {
- ...c,
- commentedBy: userId, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
-
- setInitialComments(commentWithAttachments)
- }
-
- if(rfqId) {
- setSelectedRfqIdForComments(rfqId)
- }
- setSelectedCbeId(responseId)
- setCommentSheetOpen(true)
- }
-
- // 상업 응답 시트 열기
- function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) {
- setSelectedResponseId(responseId)
- setSelectedRfq(rfq)
- setCommercialResponseSheetOpen(true)
- }
-
- // RFQ 상세 대화상자 열기
- function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) {
- setSelectedRfqId(rfqId)
- setSelectedRfqDetail(rfq)
- setRfqDetailDialogOpen(true)
- }
-
- const handleDownloadCbeFiles = React.useCallback(
- async (vendorId: number, rfqId: number) => {
- // 고유 키 생성: vendorId_rfqId
- const rowKey = `${vendorId}_${rfqId}`
-
- // 해당 협력업체의 로딩 상태만 true로 설정
- setLoadingVendors(prev => ({
- ...prev,
- [rowKey]: true
- }))
-
- try {
- const { files, error } = await fetchCbeFiles(vendorId, rfqId);
- if (error) {
- toast.error(error);
- return;
- }
- if (files.length === 0) {
- toast.warning("다운로드할 CBE 파일이 없습니다");
- return;
- }
- // 순차적으로 파일 다운로드
- for (const file of files) {
- await downloadFile(file.id);
- }
- toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`);
- } catch (error) {
- toast.error("CBE 파일을 다운로드하는 데 실패했습니다");
- console.error(error);
- } finally {
- // 해당 협력업체의 로딩 상태만 false로 되돌림
- setLoadingVendors(prev => ({
- ...prev,
- [rowKey]: false
- }))
- }
- },
- []
- );
-
- const downloadFile = React.useCallback(async (fileId: number) => {
- try {
- const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
- if (error || !file) {
- throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
- }
-
- const link = document.createElement("a");
- link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
- link.download = file.fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- return true;
- } catch (error) {
- console.error(error);
- return false;
- }
- }, []);
-
- // 응답 성공 후 데이터 갱신
- const handleResponseSuccess = React.useCallback(() => {
- // 필요한 경우 데이터 다시 가져오기
- router.refresh()
- }, [router]);
-
- // getColumns() 호출 시 필요한 핸들러들 주입
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- router,
- openCommentSheet,
- handleDownloadCbeFiles,
- loadingVendors,
- openVendorContactsDialog: openRfqDetailDialog,
- openCommercialResponseSheet,
- }),
- [
- setRowAction,
- router,
- openCommentSheet,
- handleDownloadCbeFiles,
- loadingVendors,
- openRfqDetailDialog,
- openCommercialResponseSheet
- ]
- );
-
- // 필터 필드 정의
- const filterFields: DataTableFilterField<VendorWithCbeFields>[] = []
- const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
-
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "respondedAt", desc: true }],
- columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정
- },
- getRowId: (originalRow) => String(originalRow.responseId),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- />
- </DataTable>
-
- {/* 코멘트 시트 */}
- {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && (
- <CommentSheet
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- rfqId={selectedRfqIdForComments}
- initialComments={initialComments}
- vendorId={userVendorId || 0}
- currentUserId={userId || 0}
- cbeId={selectedCbeId}
- />
- )}
-
- {/* 상업 응답 시트 */}
- {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && (
- <CommercialResponseSheet
- open={commercialResponseSheetOpen}
- onOpenChange={setCommercialResponseSheetOpen}
- responseId={selectedResponseId}
- rfq={selectedRfq}
- onSuccess={handleResponseSuccess}
- />
- )}
-
- {/* RFQ 상세 대화상자 */}
- {rfqDetailDialogOpen && selectedRfqId !== null && (
- <RfqDeailDialog
- isOpen={rfqDetailDialogOpen}
- onOpenChange={setRfqDetailDialogOpen}
- rfqId={selectedRfqId}
- rfq={selectedRfqDetail}
- />
- )}
- </>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
deleted file mode 100644
index 40d38145..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
+++ /dev/null
@@ -1,323 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput,
-} from "@/components/ui/dropzone"
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell,
-} from "@/components/ui/table"
-
-import { formatDate } from "@/lib/utils"
-import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
-
-
-export interface CbeComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-// 1) props 정의
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: CbeComment[]
- currentUserId: number
- rfqId: number
- tbeId?: number
- cbeId?: number
- vendorId: number
- onCommentsUpdated?: (comments: CbeComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 2) 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional(), // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- tbeId,
- cbeId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
-
-
- const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: [],
- },
- })
-
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles",
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
- <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // (B) 파일 드롭
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
- // (C) Submit
- async function onSubmit(data: CommentFormValues) {
- if (!rfqId) return
- startTransition(async () => {
- try {
- const res = await createRfqCommentWithAttachments({
- rfqId,
- vendorId,
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null,
- cbeId: cbeId,
- files: data.newFiles,
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 임시로 새 코멘트 추가
- const newComment: CbeComment = {
- id: res.commentId, // 서버 응답
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments:
- data.newFiles?.map((f) => ({
- id: Math.floor(Math.random() * 1e6),
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [],
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea placeholder="Enter your comment..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div
- key={field.id}
- className="flex items-center justify-between border rounded p-2"
- >
- <span className="text-sm">
- {file.name} ({prettyBytes(file.size)})
- </span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
deleted file mode 100644
index 8cc4fa6f..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Input } from "@/components/ui/input"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Textarea } from "@/components/ui/textarea"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service"
-
-// Define schema for form validation (client-side)
-const commercialResponseFormSchema = z.object({
- responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
- totalPrice: z.coerce.number().optional(),
- currency: z.string().default("USD"),
- paymentTerms: z.string().optional(),
- incoterms: z.string().optional(),
- deliveryPeriod: z.string().optional(),
- warrantyPeriod: z.string().optional(),
- validityPeriod: z.string().optional(),
- priceBreakdown: z.string().optional(),
- commercialNotes: z.string().optional(),
-})
-
-type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema>
-
-interface CommercialResponseSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- rfq: VendorWithCbeFields | null
- responseId: number | null // This is the vendor_responses.id
- onSuccess?: () => void
-}
-
-export function CommercialResponseSheet({
- rfq,
- responseId,
- onSuccess,
- ...props
-}: CommercialResponseSheetProps) {
- const [isSubmitting, startSubmitTransition] = React.useTransition()
- const [isLoading, setIsLoading] = React.useState(true)
-
- const form = useForm<CommercialResponseFormInput>({
- resolver: zodResolver(commercialResponseFormSchema),
- defaultValues: {
- responseStatus: "PENDING",
- totalPrice: undefined,
- currency: "USD",
- paymentTerms: "",
- incoterms: "",
- deliveryPeriod: "",
- warrantyPeriod: "",
- validityPeriod: "",
- priceBreakdown: "",
- commercialNotes: "",
- },
- })
-
- // Load existing commercial response data when sheet opens
- React.useEffect(() => {
- async function loadCommercialResponse() {
- if (!responseId) return
-
- setIsLoading(true)
- try {
- // Use the helper function to get existing data
- const existingResponse = await getCommercialResponseByResponseId(responseId)
-
- if (existingResponse) {
- // If we found existing data, populate the form
- form.reset({
- responseStatus: existingResponse.responseStatus,
- totalPrice: existingResponse.totalPrice,
- currency: existingResponse.currency || "USD",
- paymentTerms: existingResponse.paymentTerms || "",
- incoterms: existingResponse.incoterms || "",
- deliveryPeriod: existingResponse.deliveryPeriod || "",
- warrantyPeriod: existingResponse.warrantyPeriod || "",
- validityPeriod: existingResponse.validityPeriod || "",
- priceBreakdown: existingResponse.priceBreakdown || "",
- commercialNotes: existingResponse.commercialNotes || "",
- })
- } else if (rfq) {
- // If no existing data but we have rfq data with some values already
- form.reset({
- responseStatus: rfq.commercialResponseStatus as any || "PENDING",
- totalPrice: rfq.totalPrice || undefined,
- currency: rfq.currency || "USD",
- paymentTerms: rfq.paymentTerms || "",
- incoterms: rfq.incoterms || "",
- deliveryPeriod: rfq.deliveryPeriod || "",
- warrantyPeriod: rfq.warrantyPeriod || "",
- validityPeriod: rfq.validityPeriod || "",
- priceBreakdown: "",
- commercialNotes: "",
- })
- }
- } catch (error) {
- console.error("Failed to load commercial response data:", error)
- toast.error("상업 응답 데이터를 불러오는데 실패했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadCommercialResponse()
- }, [responseId, rfq, form])
-
- function onSubmit(formData: CommercialResponseFormInput) {
- if (!responseId) {
- toast.error("응답 ID를 찾을 수 없습니다")
- return
- }
-
- if (!rfq?.vendorId) {
- toast.error("협력업체 ID를 찾을 수 없습니다")
- return
- }
-
- startSubmitTransition(async () => {
- try {
- // Pass both responseId and vendorId to the server action
- const result = await updateCommercialResponse({
- responseId,
- vendorId: rfq.vendorId, // Include vendorId for revalidateTag
- ...formData,
- })
-
- if (!result.success) {
- toast.error(result.error || "응답 제출 중 오류가 발생했습니다")
- return
- }
-
- toast.success("Commercial response successfully submitted")
- props.onOpenChange?.(false)
-
- if (onSuccess) {
- onSuccess()
- }
- } catch (error) {
- console.error("Error submitting response:", error)
- toast.error("응답 제출 중 오류가 발생했습니다")
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>Commercial Response</SheetTitle>
- <SheetDescription>
- {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>}
- <div className="mt-1">Please provide your commercial response for this RFQ</div>
- </SheetDescription>
- </SheetHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-8">
- <Loader className="h-8 w-8 animate-spin text-muted-foreground" />
- </div>
- ) : (
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
- >
- <FormField
- control={form.control}
- name="responseStatus"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Response Status</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- >
- <FormControl>
- <SelectTrigger className="capitalize">
- <SelectValue placeholder="Select response status" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="PENDING">Pending</SelectItem>
- <SelectItem value="IN_PROGRESS">In Progress</SelectItem>
- <SelectItem value="SUBMITTED">Submitted</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="totalPrice"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Total Price</FormLabel>
- <FormControl>
- <Input
- type="number"
- placeholder="0.00"
- {...field}
- value={field.value || ''}
- onChange={(e) => {
- const value = e.target.value === '' ? undefined : parseFloat(e.target.value);
- field.onChange(value);
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Currency</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select currency" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="USD">USD</SelectItem>
- <SelectItem value="EUR">EUR</SelectItem>
- <SelectItem value="GBP">GBP</SelectItem>
- <SelectItem value="KRW">KRW</SelectItem>
- <SelectItem value="JPY">JPY</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* Other form fields remain the same */}
- <FormField
- control={form.control}
- name="paymentTerms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Payment Terms</FormLabel>
- <FormControl>
- <Input placeholder="e.g. Net 30" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="incoterms"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Incoterms</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value || ''}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select incoterms" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="EXW">EXW (Ex Works)</SelectItem>
- <SelectItem value="FCA">FCA (Free Carrier)</SelectItem>
- <SelectItem value="FOB">FOB (Free On Board)</SelectItem>
- <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem>
- <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem>
- <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="deliveryPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Delivery Period</FormLabel>
- <FormControl>
- <Input placeholder="e.g. 4-6 weeks" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="warrantyPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Warranty Period</FormLabel>
- <FormControl>
- <Input placeholder="e.g. 12 months" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="validityPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Validity Period</FormLabel>
- <FormControl>
- <Input placeholder="e.g. 30 days" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="priceBreakdown"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Price Breakdown (Optional)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter price breakdown details here"
- className="min-h-[100px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="commercialNotes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Additional Notes (Optional)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Any additional comments or notes"
- className="min-h-[100px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <SheetFooter className="gap-2 pt-4 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isSubmitting} type="submit">
- {isSubmitting && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- Submit Response
- </Button>
- </SheetFooter>
- </form>
- </Form>
- )}
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
deleted file mode 100644
index e9328641..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
-import { RfqItemsTable } from "./rfq-items-table/rfq-items-table"
-import { formatDateTime } from "@/lib/utils"
-import { CalendarClock } from "lucide-react"
-
-interface RfqDeailDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- rfqId: number | null
- rfq: VendorWithCbeFields | null
-}
-
-export function RfqDeailDialog({
- isOpen,
- onOpenChange,
- rfqId,
- rfq,
-}: RfqDeailDialogProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}>
- <DialogHeader>
- <div className="flex flex-col space-y-2">
- <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle>
- {rfq && (
- <div className="flex flex-col space-y-3 mt-2">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
- </div>
-
- {/* 정보를 두 행으로 나누어 표시 */}
- <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
- {/* 첫 번째 행: 상태 배지 */}
- <div className="flex items-center flex-wrap gap-2">
- {rfq.rfqType && (
- <Badge
- variant={
- rfq.rfqType === "BUDGETARY" ? "default" :
- rfq.rfqType === "PURCHASE" ? "destructive" :
- rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
- }
- >
- RFQ 유형: {rfq.rfqType}
- </Badge>
- )}
-
-
- {rfq.vendorStatus && (
- <Badge variant="outline">
- RFQ 상태: {rfq.rfqStatus}
- </Badge>
- )}
-
- </div>
-
- {/* 두 번째 행: Due Date를 강조 표시 */}
- {rfq.rfqDueDate && (
- <div className="flex items-center">
- <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
- <CalendarClock className="h-3.5 w-3.5" />
- <span className="font-semibold">Due Date:</span>
- <span>{formatDateTime(rfq.rfqDueDate)}</span>
- </Badge>
- </div>
- )}
- </div>
- </div>
- )}
- </div>
- </DialogHeader>
- {rfqId && (
- <div className="py-4">
- <RfqItemsTable rfqId={rfqId} />
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
deleted file mode 100644
index bf4ae709..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-"use client"
-// Because columns rely on React state/hooks for row actions
-
-import * as React from "react"
-import { ColumnDef, Row } from "@tanstack/react-table"
-import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
-import { formatDate } from "@/lib/utils"
-import { Checkbox } from "@/components/ui/checkbox"
-import { ItemData } from "./rfq-items-table"
-
-
-/** getColumns: return array of ColumnDef for 'vendors' data */
-export function getColumns(): ColumnDef<ItemData>[] {
- return [
-
- // Vendor Name
- {
- accessorKey: "itemCode",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Item Code" />
- ),
- cell: ({ row }) => row.getValue("itemCode"),
- },
-
- // Vendor Code
- {
- accessorKey: "description",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Description" />
- ),
- cell: ({ row }) => row.getValue("description"),
- },
-
- // Status
- {
- accessorKey: "quantity",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Quantity" />
- ),
- cell: ({ row }) => row.getValue("quantity"),
- },
-
-
- // Created At
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date),
- },
-
- // Updated At
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
- ),
- cell: ({ cell }) => formatDate(cell.getValue() as Date),
- },
- ]
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
deleted file mode 100644
index c5c67e54..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-'use client'
-
-import * as React from "react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { getColumns } from "./rfq-items-table-column"
-import { DataTableAdvancedFilterField } from "@/types/table"
-import { Loader2 } from "lucide-react"
-import { useToast } from "@/hooks/use-toast"
-import { getItemsByRfqId } from "../../service"
-
-export interface ItemData {
- id: number
- itemCode: string
- description: string | null
- quantity: number
- uom: string | null
- createdAt: Date
- updatedAt: Date
-}
-
-interface RFQItemsTableProps {
- rfqId: number
-}
-
-export function RfqItemsTable({ rfqId }: RFQItemsTableProps) {
- const { toast } = useToast()
-
- const columns = React.useMemo(
- () => getColumns(),
- []
- )
-
- const [rfqItems, setRfqItems] = React.useState<ItemData[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- React.useEffect(() => {
- async function loadItems() {
- setIsLoading(true)
- try {
- // Use the correct function name (camelCase)
- const result = await getItemsByRfqId(rfqId)
- if (result.success && result.data) {
- setRfqItems(result.data as ItemData[])
- } else {
- throw new Error(result.error || "Unknown error occurred")
- }
- } catch (error) {
- console.error("RFQ 아이템 로드 오류:", error)
- toast({
- title: "Error",
- description: "Failed to load RFQ items",
- variant: "destructive",
- })
- } finally {
- setIsLoading(false)
- }
- }
- loadItems()
- }, [toast, rfqId])
-
- const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [
- { id: "itemCode", label: "Item Code", type: "text" },
- { id: "description", label: "Description", type: "text" },
- { id: "quantity", label: "Quantity", type: "number" },
- { id: "uom", label: "UoM", type: "text" },
- ]
-
- // If loading, show a flex container that fills the parent and centers the spinner
- if (isLoading) {
- return (
- <div className="flex h-full w-full items-center justify-center">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- </div>
- )
- }
-
- // Otherwise, show the table
- return (
- <ClientDataTable
- data={rfqItems}
- columns={columns}
- advancedFilterFields={advancedFilterFields}
- >
- </ClientDataTable>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
deleted file mode 100644
index 504fc177..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableFooter,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { RfqWithAll } from "../types"
-/**
- * 아이템 구조 예시
- * - API 응답에서 quantity가 "string" 형태이므로,
- * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다.
- */
-export interface RfqItem {
- id: number
- itemCode: string
- itemName: string
- quantity: string
- description: string
- uom: string
-}
-
-/**
- * 첨부파일 구조 예시
- */
-export interface RfqAttachment {
- id: number
- fileName: string
- filePath: string
- vendorId: number | null
- evaluationId: number | null
-}
-
-
-/**
- * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용)
- */
-export interface DefaultItem {
- id?: number
- itemCode: string
- description?: string | null
- quantity?: number | null
- uom?: string | null
-}
-
-/**
- * RfqsItemsDialog 컴포넌트 Prop 타입
- */
-export interface RfqsItemsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- rfq: RfqWithAll
- defaultItems?: DefaultItem[]
-}
-
-export function RfqsItemsDialog({
- open,
- onOpenChange,
- rfq,
-}: RfqsItemsDialogProps) {
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-none w-[1200px]">
- <DialogHeader>
- <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle>
- <DialogDescription>
- Below is the list of items for this RFQ.
- </DialogDescription>
- </DialogHeader>
-
- <div className="overflow-x-auto w-full space-y-4">
- {rfq && rfq.items.length === 0 && (
- <p className="text-sm text-muted-foreground">No items found.</p>
- )}
- {rfq && rfq.items.length > 0 && (
- <Table>
- {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */}
- <TableHeader>
- <TableRow>
- <TableHead>Item Code</TableHead>
- <TableHead>Item Code</TableHead>
- <TableHead>Description</TableHead>
- <TableHead>Qty</TableHead>
- <TableHead>UoM</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {rfq.items.map((it, idx) => (
- <TableRow key={it.id ?? idx}>
- <TableCell>{it.itemCode || "No Code"}</TableCell>
- <TableCell>{it.itemName || "No Name"}</TableCell>
- <TableCell>{it.description || "-"}</TableCell>
- <TableCell>{it.quantity ?? 1}</TableCell>
- <TableCell>{it.uom ?? "each"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- </div>
-
- <DialogFooter className="mt-4">
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- Close
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
deleted file mode 100644
index 6c51c12c..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import { Download } from "lucide-react"
-import { formatDate } from "@/lib/utils"
-
-// 첨부파일 구조
-interface RfqAttachment {
- id: number
- fileName: string
- filePath: string
- createdAt?: Date // or Date
- vendorId?: number | null
- size?: number
-}
-
-// 컴포넌트 Prop
-interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- rfqId: number
- attachments?: RfqAttachment[]
-}
-
-/**
- * RfqAttachmentsSheet:
- * - 단순히 첨부파일 리스트 + 다운로드 버튼만
- */
-export function RfqAttachmentsSheet({
- rfqId,
- attachments = [],
- ...props
-}: RfqAttachmentsSheetProps) {
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
- <SheetHeader>
- <SheetTitle>Attachments</SheetTitle>
- <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription>
- </SheetHeader>
-
- <div className="space-y-2">
- {/* 첨부파일이 없을 경우 */}
- {attachments.length === 0 && (
- <p className="text-sm text-muted-foreground">
- No attachments
- </p>
- )}
-
- {/* 첨부파일 목록 */}
- {attachments.map((att) => (
- <div
- key={att.id}
- className="flex items-center justify-between rounded border p-2"
- >
- <div className="flex flex-col text-sm">
- <span className="font-medium">{att.fileName}</span>
- {att.size && (
- <span className="text-xs text-muted-foreground">
- {Math.round(att.size / 1024)} KB
- </span>
- )}
- {att.createdAt && (
- <span className="text-xs text-muted-foreground">
- Created at {formatDate(att.createdAt)}
- </span>
- )}
- </div>
- {/* 파일 다운로드 버튼 */}
- {att.filePath && (
- <a
- href={att.filePath}
- download
- target="_blank"
- rel="noreferrer"
- className="text-sm"
- >
- <Button variant="ghost" size="icon" type="button">
- <Download className="h-4 w-4" />
- </Button>
- </a>
- )}
- </div>
- ))}
- </div>
-
- <SheetFooter className="gap-2 pt-2">
- {/* 닫기 버튼 */}
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Close
- </Button>
- </SheetClose>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
deleted file mode 100644
index 5bb8a16a..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput,
-} from "@/components/ui/dropzone"
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell,
-} from "@/components/ui/table"
-
-import { formatDate } from "@/lib/utils"
-import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
-
-
-export interface MatchedVendorComment {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-// 1) props 정의
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- initialComments?: MatchedVendorComment[]
- currentUserId: number
- rfqId: number
- vendorId: number
- onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
- isLoading?: boolean // New prop
-}
-
-// 2) 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional(), // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
-
- console.log(initialComments)
-
- const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: [],
- },
- })
-
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles",
- })
-
- // (A) 기존 코멘트 렌더링
- function renderExistingComments() {
-
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {!c.attachments?.length && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments?.length && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
- <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // (B) 파일 드롭
- function handleDropAccepted(files: File[]) {
- append(files)
- }
-
- // (C) Submit
- async function onSubmit(data: CommentFormValues) {
- if (!rfqId) return
- startTransition(async () => {
- try {
- const res = await createRfqCommentWithAttachments({
- rfqId,
- vendorId,
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null,
- cbeId: null,
- files: data.newFiles,
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 임시로 새 코멘트 추가
- const newComment: MatchedVendorComment = {
- id: res.commentId, // 서버 응답
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: res.createdAt,
- attachments:
- data.newFiles?.map((f) => ({
- id: Math.floor(Math.random() * 1e6),
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [],
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea placeholder="Enter your comment..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div
- key={field.id}
- className="flex items-center justify-between border rounded p-2"
- >
- <span className="text-sm">
- {file.name} ({prettyBytes(file.size)})
- </span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
deleted file mode 100644
index 70b91176..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
+++ /dev/null
@@ -1,435 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { ColumnDef } from "@tanstack/react-table"
-import {
- Ellipsis,
- MessageSquare,
- Package,
- Paperclip,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { Badge } from "@/components/ui/badge"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { modifyRfqVendor } from "../../rfqs/service"
-import type { RfqWithAll } from "../types"
-import type { DataTableRowAction } from "@/types/table"
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<RfqWithAll> | null>
- >
- router: NextRouter
- openAttachmentsSheet: (rfqId: number) => void
- openCommentSheet: (rfqId: number) => void
-}
-
-/**
- * tanstack table 컬럼 정의 (Nested Header)
- */
-export function getColumns({
- setRowAction,
- router,
- openAttachmentsSheet,
- openCommentSheet,
-}: GetColumnsProps): ColumnDef<RfqWithAll>[] {
- // 1) 체크박스(Select) 컬럼
- const selectColumn: ColumnDef<RfqWithAll> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // 2) Actions (Dropdown)
- const actionsColumn: ColumnDef<RfqWithAll> = {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon">
- <Ellipsis className="h-4 w-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.responseStatus}
- onValueChange={(value) => {
- startUpdateTransition(async () => {
- let newStatus:
- | "ACCEPTED"
- | "DECLINED"
- | "REVIEWING"
-
- switch (value) {
- case "ACCEPTED":
- newStatus = "ACCEPTED"
- break
- case "DECLINED":
- newStatus = "DECLINED"
- break
- default:
- newStatus = "REVIEWING"
- }
-
- await toast.promise(
- modifyRfqVendor({
- id: row.original.responseId,
- status: newStatus,
- }),
- {
- loading: "Updating response status...",
- success: "Response status updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {[
- { value: "ACCEPTED", label: "Accept RFQ" },
- { value: "DECLINED", label: "Decline RFQ" },
- ].map((rep) => (
- <DropdownMenuRadioItem
- key={rep.value}
- value={rep.value}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {rep.label}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
- {/* <DropdownMenuItem
- onClick={() => {
- router.push(`/vendor/rfqs/${row.original.rfqId}`)
- }}
- >
- View Details
- </DropdownMenuItem> */}
- {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}>
- View Attachments
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}>
- View Comments
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}>
- View Items
- </DropdownMenuItem> */}
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // 3) RFQ Code 컬럼
- const rfqCodeColumn: ColumnDef<RfqWithAll> = {
- id: "rfqCode",
- accessorKey: "rfqCode",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
- ),
- // cell: ({ row }) => {
- // return (
- // <Button
- // variant="link"
- // className="p-0 h-auto font-medium"
- // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
- // >
- // {row.original.rfqCode}
- // </Button>
- // )
- // },
- cell: ({ row }) => row.original.rfqCode || "-",
- size: 150,
- }
-
- const rfqTypeColumn: ColumnDef<RfqWithAll> = {
- id: "rfqType",
- accessorKey: "rfqType",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Type" />
- ),
- cell: ({ row }) => row.original.rfqType || "-",
- size: 150,
- }
-
-
- // 4) 응답 상태 컬럼
- const responseStatusColumn: ColumnDef<RfqWithAll> = {
- id: "responseStatus",
- accessorKey: "responseStatus",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Response Status" />
- ),
- cell: ({ row }) => {
- const status = row.original.responseStatus;
- let variant: "default" | "secondary" | "destructive" | "outline";
-
- switch (status) {
- case "REVIEWING":
- variant = "default";
- break;
- case "ACCEPTED":
- variant = "secondary";
- break;
- case "DECLINED":
- variant = "destructive";
- break;
- default:
- variant = "outline";
- }
-
- return <Badge variant={variant}>{status}</Badge>;
- },
- size: 150,
- }
-
- // 5) 프로젝트 이름 컬럼
- const projectNameColumn: ColumnDef<RfqWithAll> = {
- id: "projectName",
- accessorKey: "projectName",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Project" />
- ),
- cell: ({ row }) => row.original.projectName || "-",
- size: 150,
- }
-
- // 6) RFQ Description 컬럼
- const descriptionColumn: ColumnDef<RfqWithAll> = {
- id: "rfqDescription",
- accessorKey: "rfqDescription",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Description" />
- ),
- cell: ({ row }) => row.original.rfqDescription || "-",
- size: 200,
- }
-
- // 7) Due Date 컬럼
- const dueDateColumn: ColumnDef<RfqWithAll> = {
- id: "rfqDueDate",
- accessorKey: "rfqDueDate",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Due Date" />
- ),
- cell: ({ row }) => {
- const date = row.original.rfqDueDate;
- return date ? formatDate(date) : "-";
- },
- size: 120,
- }
-
- // 8) Last Updated 컬럼
- const updatedAtColumn: ColumnDef<RfqWithAll> = {
- id: "respondedAt",
- accessorKey: "respondedAt",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Last Updated" />
- ),
- cell: ({ row }) => {
- const date = row.original.respondedAt;
- return date ? formatDateTime(date) : "-";
- },
- size: 150,
- }
-
- // 9) Items 컬럼 - 뱃지로 아이템 개수 표시
- const itemsColumn: ColumnDef<RfqWithAll> = {
- id: "items",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Items" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const count = rfq.items?.length ?? 0
-
- function handleClick() {
- setRowAction({ row, type: "items" })
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={count > 0 ? `View ${count} items` : "No items"}
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {count > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {count}
- </Badge>
- )}
-
- <span className="sr-only">
- {count > 0 ? `${count} Items` : "No Items"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80,
- }
-
- // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시
- const attachmentsColumn: ColumnDef<RfqWithAll> = {
- id: "attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Attachments" />
- ),
- cell: ({ row }) => {
- const attachCount = row.original.attachments?.length ?? 0
-
- function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
- e.preventDefault()
- openAttachmentsSheet(row.original.rfqId)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- attachCount > 0 ? `View ${attachCount} files` : "No files"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {attachCount}
- </Badge>
- )}
- <span className="sr-only">
- {attachCount > 0 ? `${attachCount} Files` : "No Files"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80,
- }
-
- // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시
- const commentsColumn: ColumnDef<RfqWithAll> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const commCount = row.original.comments?.length ?? 0
-
- function handleClick() {
- setRowAction({ row, type: "comments" })
- openCommentSheet(row.original.rfqId)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80,
- }
-
- // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외
- return [
- selectColumn,
- rfqCodeColumn,
- rfqTypeColumn,
- responseStatusColumn,
- projectNameColumn,
- descriptionColumn,
- dueDateColumn,
- itemsColumn,
- attachmentsColumn,
- commentsColumn,
- updatedAtColumn,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
deleted file mode 100644
index 1bae99ef..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { RfqWithAll } from "../types"
-
-
-interface RfqsTableToolbarActionsProps {
- table: Table<RfqWithAll>
-}
-
-export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) {
-
-
- return (
- <div className="flex items-center gap-2">
-
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
deleted file mode 100644
index 6aab7fef..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
+++ /dev/null
@@ -1,280 +0,0 @@
-"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { useRouter } from "next/navigation"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./rfqs-table-columns"
-import { RfqWithAll } from "../types"
-
-import {
- fetchRfqAttachments,
- fetchRfqAttachmentsbyCommentId,
-} from "../../rfqs/service"
-
-import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions"
-import { RfqsItemsDialog } from "./ItemsDialog"
-import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
-import { CommentSheet } from "./comments-sheet"
-import { getRfqResponsesForVendor } from "../service"
-import { useSession } from "next-auth/react" // Next-auth session hook 추가
-
-interface RfqsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]>
-}
-
-// 코멘트+첨부파일 구조 예시
-export interface RfqCommentWithAttachments {
- id: number
- commentText: string
- commentedBy?: number
- commentedByEmail?: string
- createdAt?: Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-export interface ExistingAttachment {
- id: number
- fileName: string
- filePath: string
- createdAt?: Date
- vendorId?: number | null
- size?: number
-}
-
-export interface ExistingItem {
- id?: number
- itemCode: string
- description: string | null
- quantity: number | null
- uom: string | null
-}
-
-export function RfqsVendorTable({ promises }: RfqsTableProps) {
- const { featureFlags } = useFeatureFlags()
- const { data: session } = useSession() // 세션 정보 가져오기
-
- // 1) 테이블 데이터( RFQs )
- const [{ data: responseData, pageCount }] = React.use(promises)
-
- // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가)
- const data: RfqWithAll[] = React.useMemo(() => {
- return responseData.map(item => ({
- ...item,
- id: item.rfqId, // id 필드를 rfqId와 동일하게 설정
- }));
- }, [responseData]);
-
- const router = useRouter()
-
- // 2) 첨부파일 시트 + 관련 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
- const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
-
- // 3) 코멘트 시트 + 관련 상태
- const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([])
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- // 4) rowAction으로 다양한 모달/시트 열기
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null)
-
- // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리
- React.useEffect(() => {
- if (rowAction?.type === "comments" && rowAction?.row.original) {
- openCommentSheet(rowAction.row.original.id)
- }
- }, [rowAction])
-
- /**
- * (A) 코멘트 시트를 열기 전에,
- * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회.
- */
- const openCommentSheet = React.useCallback(async (rfqId: number) => {
- setInitialComments([])
-
- // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기
- const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || []
-
- if (comments && comments.length > 0) {
- const commentWithAttachments = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
- return {
- ...c,
- commentedBy: c.commentedBy || 1,
- attachments,
- }
- })
- )
-
- setInitialComments(commentWithAttachments)
- }
-
- setSelectedRfqIdForComments(rfqId)
- setCommentSheetOpen(true)
- }, [data]) // data만 의존성으로 추가
-
- /**
- * (B) 첨부파일 시트 열기
- */
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
- const list = await fetchRfqAttachments(rfqId)
- setAttachDefault(list)
- setSelectedRfqIdForAttachments(rfqId)
- setAttachmentsOpen(true)
- }, [])
-
- // 5) DataTable 컬럼 세팅
- const columns = React.useMemo(
- () =>
- getColumns({
- setRowAction,
- router,
- openAttachmentsSheet,
- openCommentSheet
- }),
- [setRowAction, router, openAttachmentsSheet, openCommentSheet]
- )
-
- /**
- * 간단한 filterFields 예시
- */
- const filterFields: DataTableFilterField<RfqWithAll>[] = [
- {
- id: "rfqCode",
- label: "RFQ Code",
- placeholder: "Filter RFQ Code...",
- },
- {
- id: "projectName",
- label: "Project",
- placeholder: "Filter Project...",
- },
- {
- id: "rfqDescription",
- label: "Description",
- placeholder: "Filter Description...",
- },
- ]
-
- /**
- * Advanced filter fields 예시
- */
- const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [
- {
- id: "rfqCode",
- label: "RFQ Code",
- type: "text",
- },
- {
- id: "rfqDescription",
- label: "Description",
- type: "text",
- },
- {
- id: "projectCode",
- label: "Project Code",
- type: "text",
- },
- {
- id: "projectName",
- label: "Project Name",
- type: "text",
- },
- {
- id: "rfqDueDate",
- label: "Due Date",
- type: "date",
- },
- {
- id: "responseStatus",
- label: "Response Status",
- type: "select",
- options: [
- { label: "Reviewing", value: "REVIEWING" },
- { label: "Accepted", value: "ACCEPTED" },
- { label: "Declined", value: "DECLINED" },
- ],
- }
- ]
-
- // useDataTable() 훅 -> pagination, sorting 등 관리
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "respondedAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
- const currentVendorId = session?.user?.id ? session.user.companyId : 0
-
-
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <RfqsVendorTableToolbarActions table={table} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 1) 아이템 목록 Dialog */}
- {rowAction?.type === "items" && rowAction?.row.original && (
- <RfqsItemsDialog
- open={true}
- onOpenChange={() => setRowAction(null)}
- rfq={rowAction.row.original}
- />
- )}
-
- {/* 2) 코멘트 시트 */}
- {selectedRfqIdForComments && (
- <CommentSheet
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- initialComments={initialComments}
- rfqId={selectedRfqIdForComments}
- vendorId={currentVendorId??0}
- currentUserId={currentUserId}
- />
- )}
-
- {/* 3) 첨부파일 시트 */}
- <RfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- rfqId={selectedRfqIdForAttachments ?? 0}
- attachments={attachDefault}
- />
- </>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
deleted file mode 100644
index e0bf9727..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
+++ /dev/null
@@ -1,346 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray } from "react-hook-form"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Textarea,
-} from "@/components/ui/textarea"
-
-import {
- Dropzone,
- DropzoneZone,
- DropzoneUploadIcon,
- DropzoneTitle,
- DropzoneDescription,
- DropzoneInput
-} from "@/components/ui/dropzone"
-
-import {
- Table,
- TableHeader,
- TableRow,
- TableHead,
- TableBody,
- TableCell
-} from "@/components/ui/table"
-
-// DB 스키마에서 필요한 타입들을 가져온다고 가정
-// (실제 프로젝트에 맞춰 import를 수정하세요.)
-import { RfqWithAll } from "@/db/schema/rfq"
-import { createRfqCommentWithAttachments } from "../../rfqs/service"
-import { formatDate } from "@/lib/utils"
-
-// 코멘트 + 첨부파일 구조 (단순 예시)
-// 실제 DB 스키마에 맞춰 조정
-export interface TbeComment {
- id: number
- commentText: string
- commentedBy?: number
- createdAt?: string | Date
- attachments?: {
- id: number
- fileName: string
- filePath: string
- }[]
-}
-
-interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- /** 코멘트를 작성할 RFQ 정보 */
- /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
- initialComments?: TbeComment[]
-
- /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
- currentUserId: number
- rfqId:number
- vendorId:number
- /** 댓글 저장 후 갱신용 콜백 (옵션) */
- onCommentsUpdated?: (comments: TbeComment[]) => void
- isLoading?: boolean // New prop
-
-}
-
-// 새 코멘트 작성 폼 스키마
-const commentFormSchema = z.object({
- commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional() // File[]
-})
-type CommentFormValues = z.infer<typeof commentFormSchema>
-
-const MAX_FILE_SIZE = 30e6 // 30MB
-
-export function CommentSheet({
- rfqId,
- vendorId,
- initialComments = [],
- currentUserId,
- onCommentsUpdated,
- isLoading = false, // Default to false
- ...props
-}: CommentSheetProps) {
- const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
- const [isPending, startTransition] = React.useTransition()
-
- React.useEffect(() => {
- setComments(initialComments)
- }, [initialComments])
-
-
- // RHF 세팅
- const form = useForm<CommentFormValues>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- commentText: "",
- newFiles: []
- }
- })
-
- // formFieldArray 예시 (파일 목록)
- const { fields: newFileFields, append, remove } = useFieldArray({
- control: form.control,
- name: "newFiles"
- })
-
- // 1) 기존 코멘트 + 첨부 보여주기
- // 간단히 테이블 하나로 표현
- // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
- function renderExistingComments() {
- if (isLoading) {
- return (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
- <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
- </div>
- )
- }
-
- if (comments.length === 0) {
- return <p className="text-sm text-muted-foreground">No comments yet</p>
- }
-
- return (
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-1/2">Comment</TableHead>
- <TableHead>Attachments</TableHead>
- <TableHead>Created At</TableHead>
- <TableHead>Created By</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {comments.map((c) => (
- <TableRow key={c.id}>
- <TableCell>{c.commentText}</TableCell>
- <TableCell>
- {/* 첨부파일 표시 */}
- {(!c.attachments || c.attachments.length === 0) && (
- <span className="text-sm text-muted-foreground">No files</span>
- )}
- {c.attachments && c.attachments.length > 0 && (
- <div className="flex flex-col gap-1">
- {c.attachments.map((att) => (
- <div key={att.id} className="flex items-center gap-2">
- <a
- href={att.filePath}
- download
- target="_blank"
- rel="noreferrer"
- className="inline-flex items-center gap-1 text-blue-600 underline"
- >
- <Download className="h-4 w-4" />
- {att.fileName}
- </a>
- </div>
- ))}
- </div>
- )}
- </TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
- <TableCell>
- {c.commentedBy ?? "-"}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )
- }
-
- // 2) 새 파일 Drop
- function handleDropAccepted(files: File[]) {
- // 드롭된 File[]을 RHF field array에 추가
- const toAppend = files.map((f) => f)
- append(toAppend)
- }
-
-
- // 3) 저장(Submit)
- async function onSubmit(data: CommentFormValues) {
-
- if (!rfqId) return
- startTransition(async () => {
- try {
- // 서버 액션 호출
- const res = await createRfqCommentWithAttachments({
- rfqId: rfqId,
- vendorId: vendorId, // 필요시 세팅
- commentText: data.commentText,
- commentedBy: currentUserId,
- evaluationId: null, // 필요시 세팅
- files: data.newFiles
- })
-
- if (!res.ok) {
- throw new Error("Failed to create comment")
- }
-
- toast.success("Comment created")
-
- // 새 코멘트를 다시 불러오거나,
- // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
- const newComment: TbeComment = {
- id: res.commentId, // 서버에서 반환된 commentId
- commentText: data.commentText,
- commentedBy: currentUserId,
- createdAt: new Date().toISOString(),
- attachments: (data.newFiles?.map((f, idx) => ({
- id: Math.random() * 100000,
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [])
- }
- setComments((prev) => [...prev, newComment])
- onCommentsUpdated?.([...comments, newComment])
-
- // 폼 리셋
- form.reset()
- } catch (err: any) {
- console.error(err)
- toast.error("Error: " + err.message)
- }
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
- <SheetHeader className="text-left">
- <SheetTitle>Comments</SheetTitle>
- <SheetDescription>
- 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
- </SheetDescription>
- </SheetHeader>
-
- {/* 기존 코멘트 목록 */}
- <div className="max-h-[300px] overflow-y-auto">
- {renderExistingComments()}
- </div>
-
- {/* 새 코멘트 작성 Form */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- <FormField
- control={form.control}
- name="commentText"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Comment</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter your comment..."
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Dropzone (파일 첨부) */}
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={(rej) => {
- toast.error("File rejected: " + (rej[0]?.file?.name || ""))
- }}
- >
- {({ maxSize }) => (
- <DropzoneZone className="flex justify-center">
- <DropzoneInput />
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop to attach files</DropzoneTitle>
- <DropzoneDescription>
- Max size: {prettyBytes(maxSize || 0)}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- )}
- </Dropzone>
-
- {/* 선택된 파일 목록 */}
- {newFileFields.length > 0 && (
- <div className="flex flex-col gap-2">
- {newFileFields.map((field, idx) => {
- const file = form.getValues(`newFiles.${idx}`)
- if (!file) return null
- return (
- <div key={field.id} className="flex items-center justify-between border rounded p-2">
- <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={() => remove(idx)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- )
- })}
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
deleted file mode 100644
index 2056a48f..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { formatDateTime } from "@/lib/utils"
-import { CalendarClock } from "lucide-react"
-import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table"
-import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
-
-interface RfqDeailDialogProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- rfqId: number | null
- rfq: TbeVendorFields | null
-}
-
-export function RfqDeailDialog({
- isOpen,
- onOpenChange,
- rfqId,
- rfq,
-}: RfqDeailDialogProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
- <DialogHeader>
- <div className="flex flex-col space-y-2">
- <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle>
- {rfq && (
- <div className="flex flex-col space-y-3 mt-2">
- <div className="text-sm text-muted-foreground">
- <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
- </div>
-
- {/* 정보를 두 행으로 나누어 표시 */}
- <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
- {/* 첫 번째 행: 상태 배지 */}
- <div className="flex items-center flex-wrap gap-2">
- {rfq.vendorStatus && (
- <Badge variant="outline">
- {rfq.rfqStatus}
- </Badge>
- )}
- {rfq.rfqType && (
- <Badge
- variant={
- rfq.rfqType === "BUDGETARY" ? "default" :
- rfq.rfqType === "PURCHASE" ? "destructive" :
- rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
- }
- >
- {rfq.rfqType}
- </Badge>
- )}
- </div>
-
- {/* 두 번째 행: Due Date를 강조 표시 */}
- {rfq.rfqDueDate && (
- <div className="flex items-center">
- <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
- <CalendarClock className="h-3.5 w-3.5" />
- <span className="font-semibold">Due Date:</span>
- <span>{formatDateTime(rfq.rfqDueDate)}</span>
- </Badge>
- </div>
- )}
- </div>
- </div>
- )}
- </div>
- </DialogHeader>
- {rfqId && (
- <div className="py-4">
- <RfqItemsTable rfqId={rfqId} />
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
deleted file mode 100644
index f664d9a3..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
+++ /dev/null
@@ -1,350 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Download, MessageSquare, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-import {
- tbeVendorColumnsConfig,
- VendorTbeColumnConfig,
- vendorTbeColumnsConfig,
- TbeVendorFields,
-} from "@/config/vendorTbeColumnsConfig"
-
-type NextRouter = ReturnType<typeof useRouter>
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<TbeVendorFields> | null>
- >
- router: NextRouter
- openCommentSheet: (vendorId: number) => void
- handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
- handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
- openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처
-
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({
- setRowAction,
- router,
- openCommentSheet,
- handleDownloadTbeTemplate,
- handleUploadTbeResponse,
- openVendorContactsDialog
-}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
- // ----------------------------------------------------------------
- // 1) Select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<TbeVendorFields> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 그룹화(Nested) 컬럼 구성
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {}
-
- tbeVendorColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // childCol: ColumnDef<TbeVendorFields>
- const childCol: ColumnDef<TbeVendorFields> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- maxSize: 120,
- // 셀 렌더링
- cell: ({ row, getValue }) => {
- // 1) 필드값 가져오기
- const val = getValue()
-
- if (cfg.id === "vendorStatus") {
- const statusVal = row.original.vendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <Badge variant="outline">
- {statusVal}
- </Badge>
- )
- }
-
-
- if (cfg.id === "rfqCode") {
- const rfq = row.original;
- const rfqId = rfq.rfqId;
-
- // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
- const handleVendorNameClick = () => {
- if (rfqId) {
- openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
- } else {
- toast.error("협력업체 ID를 찾을 수 없습니다.");
- }
- };
-
- return (
- <Button
- variant="link"
- className="p-0 h-auto text-left font-normal justify-start hover:underline"
- onClick={handleVendorNameClick}
- >
- {val as string}
- </Button>
- );
- }
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline"
- return (
- <Badge variant={variant}>
- {statusVal}
- </Badge>
- )
- }
-
- // 예) TBE Updated (날짜)
- if (cfg.id === "tbeUpdated") {
- const dateVal = val as Date | undefined
- if (!dateVal) return null
- return formatDate(dateVal)
- }
-
- // 그 외 필드는 기본 값 표시
- return val ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // groupMap → nestedColumns
- const nestedColumns: ColumnDef<TbeVendorFields>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 3) Comments 컬럼
- // ----------------------------------------------------------------
- const commentsColumn: ColumnDef<TbeVendorFields> = {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
-
- function handleClick() {
- // rowAction + openCommentSheet
- setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.tbeId ?? 0)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- commCount > 0 ? `View ${commCount} comments` : "No comments"
- }
- >
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {commCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {commCount}
- </Badge>
- )}
- <span className="sr-only">
- {commCount > 0 ? `${commCount} Comments` : "No Comments"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80
- }
-
- // ----------------------------------------------------------------
- // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능
- // ----------------------------------------------------------------
- const tbeDownloadColumn: ColumnDef<TbeVendorFields> = {
- id: "tbeDownload",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TBE Sheets" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const tbeId = vendor.tbeId
- const vendorId = vendor.vendorId
- const rfqId = vendor.rfqId
- const templateFileCount = vendor.templateFileCount || 0
-
- if (!tbeId || !vendorId || !rfqId) {
- return <div className="text-center text-muted-foreground">-</div>
- }
-
- // 템플릿 파일이 없으면 다운로드 버튼 비활성화
- const isDisabled = templateFileCount <= 0
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={
- isDisabled
- ? undefined
- : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId)
- }
- aria-label={
- templateFileCount > 0
- ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
- : "다운로드할 파일 없음"
- }
- disabled={isDisabled}
- >
- <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
-
- {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */}
- {templateFileCount > 0 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
- >
- {templateFileCount}
- </Badge>
- )}
-
- <span className="sr-only">
- {templateFileCount > 0
- ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
- : "다운로드할 파일 없음"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- maxSize: 80,
- }
- // ----------------------------------------------------------------
- // 5) TBE 업로드 컬럼 - 응답 업로드 기능
- // ----------------------------------------------------------------
- const tbeUploadColumn: ColumnDef<TbeVendorFields> = {
- id: "tbeUpload",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Upload Response" />
- ),
- cell: ({ row }) => {
- const vendor = row.original
- const tbeId = vendor.tbeId
- const vendorId = vendor.vendorId
- const rfqId = vendor.rfqId
- const vendorResponseId = vendor.vendorResponseId || 0
- const status = vendor.rfqVendorStatus
- const hasResponse = vendor.hasResponse || false
-
-
- if (!tbeId || !vendorId || !rfqId || status === "REJECTED") {
- return <div className="text-center text-muted-foreground">-</div>
- }
-
- return (
- <div >
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0 group relative"
- onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)}
- aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"}
- >
- <div className="flex items-center justify-center relative">
- <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- </div>
- {hasResponse && (
- <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span>
- )}
- <span className="sr-only">
- {"TBE 응답 업로드"}
- </span>
- </Button>
- </div>
- )
- },
- enableSorting: false,
- maxSize: 80
- }
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- commentsColumn,
- tbeDownloadColumn,
- tbeUploadColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
deleted file mode 100644
index 13d5dc64..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { toast } from "sonner"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getColumns } from "./tbe-table-columns"
-import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service"
-import { CommentSheet, TbeComment } from "./comments-sheet"
-import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
-import { useTbeFileHandlers } from "./tbeFileHandler"
-import { useSession } from "next-auth/react"
-import { RfqDeailDialog } from "./rfq-detail-dialog"
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getTBEforVendor>>,
- ]
- >
-}
-
-export function TbeVendorTable({ promises }: VendorsTableProps) {
- const { data: session } = useSession()
- const userVendorId = session?.user?.companyId
- const userId = Number(session?.user?.id)
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null)
-
-
- // router 획득
- const router = useRouter()
-
- const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
- const [isLoadingComments, setIsLoadingComments] = React.useState(false)
-
- const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
- const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false)
-
- const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
- const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null)
-
- const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => {
- setSelectedRfqId(rfqId)
- setSelectedRfq(rfq)
- setIsRfqDetailDialogOpen(true)
- }
-
- // TBE 파일 핸들러 훅 사용
- const {
- handleDownloadTbeTemplate,
- handleUploadTbeResponse,
- UploadDialog,
- } = useTbeFileHandlers()
-
- React.useEffect(() => {
- if (rowAction?.type === "comments") {
- // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.id))
- }
- }, [rowAction])
-
- async function openCommentSheet(vendorId: number) {
- setInitialComments([])
- setIsLoadingComments(true)
-
- const comments = rowAction?.row.original.comments
-
- try {
- if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
- comments.map(async (c) => {
- // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
-
- return {
- ...c,
- commentedBy: userId, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
-
- setInitialComments(commentWithAttachments)
- }
-
- setSelectedRfqIdForComments(vendorId)
- setCommentSheetOpen(true)
-
- } catch (error) {
- console.error("Error loading comments:", error)
- toast.error("Failed to load comments")
- } finally {
- // End loading regardless of success/failure
- setIsLoadingComments(false)
- }
-}
-
- // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- router,
- openCommentSheet,
- handleDownloadTbeTemplate,
- handleUploadTbeResponse,
- openVendorContactsDialog
- }),
- [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog]
- )
-
- const filterFields: DataTableFilterField<TbeVendorFields>[] = []
-
- const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [
- { id: "rfqCode", label: "RFQ Code", type: "text" },
- { id: "projectCode", label: "Project Code", type: "text" },
- { id: "projectName", label: "Project Name", type: "text" },
- { id: "rfqCode", label: "RFQ Code", type: "text" },
- { id: "tbeResult", label: "TBE Result", type: "text" },
- { id: "tbeNote", label: "TBE Note", type: "text" },
- { id: "rfqCode", label: "RFQ Code", type: "text" },
- { id: "hasResponse", label: "Response?", type: "boolean" },
- { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
- { id: "dueDate", label: "Project Name", type: "date" },
-
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- />
- </DataTable>
-
- {/* 코멘트 시트 */}
- {commentSheetOpen && selectedRfqIdForComments && (
- <CommentSheet
- open={commentSheetOpen}
- onOpenChange={setCommentSheetOpen}
- rfqId={selectedRfqIdForComments}
- initialComments={initialComments}
- vendorId={userVendorId || 0}
- currentUserId={userId || 0}
- isLoading={isLoadingComments} // Pass the loading state
-
- />
- )}
-
- <RfqDeailDialog
- isOpen={isRfqDetailDialogOpen}
- onOpenChange={setIsRfqDetailDialogOpen}
- rfqId={selectedRfqId}
- rfq={selectedRfq}
- />
-
- {/* TBE 파일 다이얼로그 */}
- <UploadDialog />
- </>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
deleted file mode 100644
index a0b6f805..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-"use client";
-
-import { useCallback, useState, useEffect } from "react";
-import { toast } from "sonner";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import {
- fetchTbeTemplateFiles,
- uploadTbeResponseFile,
- getTbeSubmittedFiles,
- getFileFromRfqAttachmentsbyid,
-} from "../../rfqs/service";
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone";
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list";
-import { Download, X } from "lucide-react";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { formatDateTime } from "@/lib/utils";
-
-export function useTbeFileHandlers() {
- // 모달 열림 여부, 현재 선택된 IDs
- const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
- const [currentTbeId, setCurrentTbeId] = useState<number | null>(null);
- const [currentVendorId, setCurrentVendorId] = useState<number | null>(null);
- const [currentRfqId, setCurrentRfqId] = useState<number | null>(null);
- const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null);
-
-
-
- // 로딩 상태들
- const [isLoading, setIsLoading] = useState(false);
- const [isFetchingFiles, setIsFetchingFiles] = useState(false);
-
- // 업로드할 파일, 제출된 파일 목록
- const [selectedFile, setSelectedFile] = useState<File | null>(null);
- const [submittedFiles, setSubmittedFiles] = useState<
- Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>
- >([]);
-
- // ===================================
- // 1) 제출된 파일 목록 가져오기
- // ===================================
- const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => {
- if (!vendorResponseId ) return;
-
- setIsFetchingFiles(true);
- try {
- const { files, error } = await getTbeSubmittedFiles(vendorResponseId);
- if (error) {
- console.error(error);
- return;
- }
- setSubmittedFiles(files);
- } catch (error) {
- console.error("Failed to fetch submitted files:", error);
- } finally {
- setIsFetchingFiles(false);
- }
- }, []);
-
- // ===================================
- // 2) TBE 템플릿 다운로드
- // ===================================
- const handleDownloadTbeTemplate = useCallback(
- async (tbeId: number, vendorId: number, rfqId: number) => {
- setCurrentTbeId(tbeId);
- setCurrentVendorId(vendorId);
- setCurrentRfqId(rfqId);
- setIsLoading(true);
-
- try {
- const { files, error } = await fetchTbeTemplateFiles(tbeId);
- if (error) {
- toast.error(error);
- return;
- }
- if (files.length === 0) {
- toast.warning("다운로드할 템플릿 파일이 없습니다");
- return;
- }
- // 순차적으로 파일 다운로드
- for (const file of files) {
- await downloadFile(file.id);
- }
- toast.success("모든 템플릿 파일이 다운로드되었습니다");
- } catch (error) {
- toast.error("템플릿 파일을 다운로드하는 데 실패했습니다");
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- },
- []
- );
-
- // 실제 다운로드 로직
- const downloadFile = useCallback(async (fileId: number) => {
- try {
- const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
- if (error || !file) {
- throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
- }
-
- const link = document.createElement("a");
- link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
- link.download = file.fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- return true;
- } catch (error) {
- console.error(error);
- return false;
- }
- }, []);
-
- // ===================================
- // 3) 제출된 파일 다운로드
- // ===================================
- const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => {
- try {
- const link = document.createElement("a");
- link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`;
- link.download = file.fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- toast.success(`${file.fileName} 다운로드 시작`);
- } catch (error) {
- console.error("Failed to download file:", error);
- toast.error("파일 다운로드에 실패했습니다");
- }
- }, []);
-
- // ===================================
- // 4) TBE 응답 업로드 모달 열기
- // (이 시점에서는 데이터 fetch하지 않음)
- // ===================================
- const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => {
- setCurrentTbeId(tbeId);
- setCurrentVendorId(vendorId);
- setCurrentRfqId(rfqId);
- setCurrentvendorResponseId(vendorResponseId);
- setIsUploadDialogOpen(true);
- }, []);
-
- // ===================================
- // 5) Dialog 열고 닫힐 때 상태 초기화
- // 열렸을 때 -> useEffect로 파일 목록 가져오기
- // ===================================
- useEffect(() => {
- if (!isUploadDialogOpen) {
- // 닫힐 때는 파일 상태들 초기화
- setSelectedFile(null);
- setSubmittedFiles([]);
- }
- }, [isUploadDialogOpen]);
-
- useEffect(() => {
- // Dialog가 열렸고, ID들이 유효하면
- if (isUploadDialogOpen &&currentvendorResponseId) {
- fetchSubmittedFiles(currentvendorResponseId);
- }
- }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]);
-
- // ===================================
- // 6) 드롭존 파일 선택 & 제거
- // ===================================
- const handleFileDrop = useCallback((files: File[]) => {
- if (files && files.length > 0) {
- setSelectedFile(files[0]);
- }
- }, []);
-
- const handleRemoveFile = useCallback(() => {
- setSelectedFile(null);
- }, []);
-
- // ===================================
- // 7) 응답 파일 업로드
- // ===================================
- const handleSubmitResponse = useCallback(async () => {
- if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) {
- toast.error("업로드할 파일을 선택해주세요");
- return;
- }
-
- setIsLoading(true);
- try {
- // FormData 생성
- const formData = new FormData();
- formData.append("file", selectedFile);
- formData.append("rfqId", currentRfqId.toString());
- formData.append("vendorId", currentVendorId.toString());
- formData.append("evaluationId", currentTbeId.toString());
- formData.append("vendorResponseId", currentvendorResponseId.toString());
-
- const result = await uploadTbeResponseFile(formData);
- if (!result.success) {
- throw new Error(result.error || "파일 업로드에 실패했습니다");
- }
-
- toast.success(result.message || "응답이 성공적으로 업로드되었습니다");
-
- // 업로드 후 다시 제출된 파일 목록 가져오기
- await fetchSubmittedFiles(currentvendorResponseId);
-
- // 업로드 성공 시 선택 파일 초기화
- setSelectedFile(null);
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다");
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]);
-
- // ===================================
- // 8) 실제 Dialog 컴포넌트
- // ===================================
- const UploadDialog = () => (
- <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
- <DialogContent className="sm:max-w-lg">
- <DialogHeader>
- <DialogTitle>TBE 응답 파일</DialogTitle>
- <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription>
- </DialogHeader>
-
- <Tabs defaultValue="upload" className="w-full">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="upload">새 파일 업로드</TabsTrigger>
- <TabsTrigger
- value="submitted"
- disabled={submittedFiles.length === 0}
- className={submittedFiles.length > 0 ? "relative" : ""}
- >
- 제출된 파일{" "}
- {submittedFiles.length > 0 && (
- <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground">
- {submittedFiles.length}
- </span>
- )}
- </TabsTrigger>
- </TabsList>
-
- {/* 업로드 탭 */}
- <TabsContent value="upload" className="pt-4">
- <div className="grid gap-4">
- {selectedFile ? (
- <FileList>
- <FileListItem>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{selectedFile.name}</FileListName>
- <FileListSize>{selectedFile.size}</FileListSize>
- </FileListInfo>
- <FileListAction>
- <Button variant="ghost" size="icon" onClick={handleRemoveFile}>
- <X className="h-4 w-4" />
- <span className="sr-only">파일 제거</span>
- </Button>
- </FileListAction>
- </FileListItem>
- </FileList>
- ) : (
- <Dropzone onDrop={handleFileDrop}>
- <DropzoneInput className="sr-only" />
- <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6">
- <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
- <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
- <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription>
- </DropzoneZone>
- </Dropzone>
- )}
-
- <DialogFooter className="mt-4">
- <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}>
- {isLoading ? "업로드 중..." : "응답 업로드"}
- </Button>
- </DialogFooter>
- </div>
- </TabsContent>
-
- {/* 제출된 파일 탭 */}
- <TabsContent value="submitted" className="pt-4">
- {isFetchingFiles ? (
- <div className="flex justify-center items-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
- </div>
- ) : submittedFiles.length > 0 ? (
- <div className="grid gap-2">
- <FileList>
- {submittedFiles.map((file) => (
- <FileListItem key={file.id} className="flex items-center justify-between gap-3">
- <div className="flex items-center gap-3 flex-1">
- <FileListIcon className="flex-shrink-0" />
- <FileListInfo className="flex-1 min-w-0">
- <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
- <FileListDescription className="text-xs text-muted-foreground">
- {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
- </FileListDescription>
- </FileListInfo>
- </div>
- <FileListAction className="flex-shrink-0 ml-2">
- <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
- <Download className="h-4 w-4" />
- <span className="sr-only">파일 다운로드</span>
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
- )}
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
- );
-
- // ===================================
- // 9) Hooks 내보내기
- // ===================================
- return {
- handleDownloadTbeTemplate,
- handleUploadTbeResponse,
- UploadDialog,
- };
-} \ No newline at end of file
diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx
index a9fe0e1a..d272a0ea 100644
--- a/lib/vendors/table/request-project-pq-dialog.tsx
+++ b/lib/vendors/table/request-project-pq-dialog.tsx
@@ -43,7 +43,7 @@ import {
import { Label } from "@/components/ui/label"
import { Vendor } from "@/db/schema/vendors"
import { requestPQVendors } from "../service"
-import { getProjects, type Project } from "@/lib/rfqs/service"
+import { getProjects } from "@/lib/projects/service"
import { useSession } from "next-auth/react"
interface RequestProjectPQDialogProps
@@ -53,6 +53,13 @@ interface RequestProjectPQDialogProps
onSuccess?: () => void
}
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ type: string;
+}
+
export function RequestProjectPQDialog({
vendors,
showTrigger = true,
diff --git a/middleware.ts b/middleware.ts
index 6a825e6f..2ff8408e 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -82,22 +82,6 @@ function getDashboardPath(domain: string, lng: string): string {
}
}
-// 도메인별 로그인 페이지 경로 정의
-function getLoginPath(domain: string, lng: string): string {
- switch (domain) {
- case 'partners':
- return `/${lng}/partners`;
- case 'pending':
- return `/${lng}/pending`;
- case 'evcp':
- case 'procurement':
- case 'sales':
- case 'engineering':
- default:
- return `/${lng}/evcp`;
- }
-}
-
// 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환
function getDomainRedirectPath(path: string, domain: string, lng: string) {
// 도메인이 없는 경우 리다이렉트 없음
@@ -107,14 +91,14 @@ function getDomainRedirectPath(path: string, domain: string, lng: string) {
const domainPatterns = {
pending: `/pending`,
evcp: `/evcp`,
- procurement: `/procurement`,
- sales: `/sales`,
- engineering: `/engineering`,
+ procurement: `/evcp`,
+ sales: `/evcp`,
+ engineering: `/evcp`,
partners: `/partners`
};
// 현재 경로가 어떤 도메인 패턴에 속하는지 확인
- let currentPathDomain = null;
+ let currentPathDomain: string | null = null;
for (const [domainName, pattern] of Object.entries(domainPatterns)) {
// 정확한 매칭을 위해 언어 코드를 포함한 전체 패턴으로 확인
const fullPattern = `/${lng}${pattern}`;