From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Mar 2025 15:55:45 +0900 Subject: initial commit --- lib/admin-users/repository.ts | 171 ++ lib/admin-users/service.ts | 531 ++++ lib/admin-users/table/add-ausers-dialog.tsx | 348 +++ lib/admin-users/table/ausers-table-columns.tsx | 228 ++ .../table/ausers-table-floating-bar.tsx | 389 +++ .../table/ausers-table-toolbar-actions.tsx | 118 + lib/admin-users/table/ausers-table.tsx | 180 ++ lib/admin-users/table/delete-ausers-dialog.tsx | 149 ++ lib/admin-users/table/update-auser-sheet.tsx | 225 ++ lib/admin-users/validations.ts | 65 + lib/compose-refs.ts | 38 + lib/constants.ts | 3 + lib/data-table.ts | 181 ++ lib/docuSign/docuSignFns.ts | 383 +++ lib/docuSign/jwtConfig/README.md | 54 + lib/docuSign/jwtConfig/jwtConfig.json | 6 + lib/docuSign/jwtConfig/private.key | 29 + lib/docuSign/types.ts | 37 + lib/downloadFile.ts | 81 + lib/equip-class/repository.ts | 45 + lib/equip-class/service.ts | 85 + lib/equip-class/table/equipClass-table-columns.tsx | 99 + .../table/equipClass-table-toolbar-actions.tsx | 53 + lib/equip-class/table/equipClass-table.tsx | 133 + lib/equip-class/table/feature-flags-provider.tsx | 108 + lib/equip-class/validation.ts | 34 + lib/export.ts | 198 ++ lib/export_all.ts | 251 ++ lib/filter-columns.ts | 193 ++ lib/fonts.ts | 5 + lib/form-list/repository.ts | 46 + lib/form-list/service.ts | 84 + lib/form-list/table/feature-flags-provider.tsx | 108 + lib/form-list/table/formLists-table-columns.tsx | 132 + .../table/formLists-table-toolbar-actions.tsx | 53 + lib/form-list/table/formLists-table.tsx | 151 ++ lib/form-list/table/meta-sheet.tsx | 245 ++ lib/form-list/validation.ts | 36 + lib/forms/services.ts | 645 +++++ lib/handle-error.ts | 22 + lib/id.ts | 43 + lib/items/repository.ts | 125 + lib/items/service.ts | 201 ++ lib/items/table/add-items-dialog.tsx | 156 ++ lib/items/table/delete-items-dialog.tsx | 149 ++ lib/items/table/feature-flags-provider.tsx | 108 + lib/items/table/feature-flags.tsx | 96 + lib/items/table/items-table-columns.tsx | 183 ++ lib/items/table/items-table-toolbar-actions.tsx | 67 + lib/items/table/items-table.tsx | 139 + lib/items/table/update-item-sheet.tsx | 178 ++ lib/items/validations.ts | 47 + lib/logger.ts | 26 + lib/mail/mailer.ts | 31 + lib/mail/sendEmail.ts | 36 + lib/mail/templates/admin-created.hbs | 78 + lib/mail/templates/admin-email-changed.hbs | 90 + lib/mail/templates/otp.hbs | 77 + lib/mail/templates/rfq-invite.hbs | 116 + lib/mail/templates/vendor-active.hbs | 51 + lib/mail/templates/vendor-pq-comment.hbs | 128 + lib/mail/templates/vendor-pq-status.hbs | 48 + lib/parsers.ts | 94 + lib/po/repository.ts | 44 + lib/po/service.ts | 431 +++ lib/po/service_r1.ts | 282 ++ lib/po/table/feature-flags-provider.tsx | 108 + lib/po/table/po-table-columns.tsx | 155 ++ lib/po/table/po-table-toolbar-actions.tsx | 53 + lib/po/table/po-table.tsx | 164 ++ lib/po/table/sign-request-dialog.tsx | 410 +++ lib/po/validations.ts | 67 + lib/pq/pq-review-table/feature-flags-provider.tsx | 108 + lib/pq/pq-review-table/vendors-table-columns.tsx | 212 ++ .../vendors-table-toolbar-actions.tsx | 41 + lib/pq/pq-review-table/vendors-table.tsx | 97 + lib/pq/repository.ts | 44 + lib/pq/service.ts | 987 +++++++ lib/pq/table/add-pq-dialog.tsx | 299 +++ lib/pq/table/delete-pqs-dialog.tsx | 149 ++ lib/pq/table/pq-table-column.tsx | 185 ++ lib/pq/table/pq-table-toolbar-actions.tsx | 55 + lib/pq/table/pq-table.tsx | 125 + lib/pq/table/update-pq-sheet.tsx | 272 ++ lib/pq/validations.ts | 36 + lib/rfqs/cbe-table/cbe-table-columns.tsx | 227 ++ lib/rfqs/cbe-table/cbe-table.tsx | 161 ++ lib/rfqs/cbe-table/feature-flags-provider.tsx | 108 + lib/rfqs/repository.ts | 232 ++ lib/rfqs/service.ts | 2783 ++++++++++++++++++++ lib/rfqs/table/BudgetaryRfqSelector.tsx | 261 ++ lib/rfqs/table/ItemsDialog.tsx | 744 ++++++ lib/rfqs/table/add-rfq-dialog.tsx | 349 +++ lib/rfqs/table/attachment-rfq-sheet.tsx | 430 +++ lib/rfqs/table/delete-rfqs-dialog.tsx | 149 ++ lib/rfqs/table/feature-flags-provider.tsx | 108 + lib/rfqs/table/feature-flags.tsx | 96 + lib/rfqs/table/rfqs-table-columns.tsx | 315 +++ lib/rfqs/table/rfqs-table-floating-bar.tsx | 338 +++ lib/rfqs/table/rfqs-table-toolbar-actions.tsx | 55 + lib/rfqs/table/rfqs-table.tsx | 264 ++ lib/rfqs/table/update-rfq-sheet.tsx | 283 ++ lib/rfqs/tbe-table/comments-sheet.tsx | 334 +++ lib/rfqs/tbe-table/feature-flags-provider.tsx | 108 + lib/rfqs/tbe-table/file-dialog.tsx | 141 + lib/rfqs/tbe-table/invite-vendors-dialog.tsx | 203 ++ lib/rfqs/tbe-table/tbe-table-columns.tsx | 307 +++ lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx | 60 + lib/rfqs/tbe-table/tbe-table.tsx | 190 ++ lib/rfqs/validations.ts | 274 ++ lib/rfqs/vendor-table/add-vendor-dialog.tsx | 37 + lib/rfqs/vendor-table/comments-sheet.tsx | 303 +++ lib/rfqs/vendor-table/feature-flags-provider.tsx | 108 + lib/rfqs/vendor-table/invite-vendors-dialog.tsx | 177 ++ .../vendor-list/vendor-list-table-column.tsx | 154 ++ .../vendor-table/vendor-list/vendor-list-table.tsx | 142 + lib/rfqs/vendor-table/vendors-table-columns.tsx | 264 ++ .../vendor-table/vendors-table-floating-bar.tsx | 137 + .../vendor-table/vendors-table-toolbar-actions.tsx | 84 + lib/rfqs/vendor-table/vendors-table.tsx | 181 ++ lib/roles/repository.ts | 94 + lib/roles/services.ts | 300 +++ lib/roles/table/add-role-dialog.tsx | 308 +++ lib/roles/table/assign-roles-sheet.tsx | 87 + lib/roles/table/delete-roles-dialog.tsx | 149 ++ lib/roles/table/role-table-toolbar-actions.tsx | 101 + lib/roles/table/roles-table-columns.tsx | 223 ++ lib/roles/table/roles-table.tsx | 169 ++ lib/roles/table/update-roles-sheet.tsx | 331 +++ .../userTable/assginedUsers-table-columns.tsx | 164 ++ lib/roles/userTable/assignedUsers-table.tsx | 159 ++ lib/roles/validations.ts | 80 + lib/storage.ts | 44 + lib/tag-numbering/repository.ts | 45 + lib/tag-numbering/service.ts | 123 + lib/tag-numbering/table/feature-flags-provider.tsx | 108 + lib/tag-numbering/table/meta-sheet.tsx | 226 ++ .../table/tagNumbering-table-columns.tsx | 131 + .../table/tagNumbering-table-toolbar-actions.tsx | 53 + lib/tag-numbering/table/tagNumbering-table.tsx | 151 ++ lib/tag-numbering/validation.ts | 39 + lib/tags/form-mapping-service.ts | 65 + lib/tags/repository.ts | 71 + lib/tags/service.ts | 796 ++++++ lib/tags/table/add-tag-dialog copy.tsx | 637 +++++ lib/tags/table/add-tag-dialog.tsx | 893 +++++++ lib/tags/table/delete-tags-dialog.tsx | 151 ++ lib/tags/table/feature-flags-provider.tsx | 108 + lib/tags/table/tag-table-column.tsx | 164 ++ lib/tags/table/tag-table.tsx | 141 + lib/tags/table/tags-export.tsx | 155 ++ lib/tags/table/tags-table-floating-bar.tsx | 220 ++ lib/tags/table/tags-table-toolbar-actions.tsx | 598 +++++ lib/tags/table/update-tag-sheet.tsx | 548 ++++ lib/tags/validations.ts | 68 + lib/tasks/repository.ts | 166 ++ lib/tasks/service.ts | 561 ++++ lib/tasks/table/add-tasks-dialog.tsx | 227 ++ lib/tasks/table/delete-tasks-dialog.tsx | 149 ++ lib/tasks/table/feature-flags-provider.tsx | 108 + lib/tasks/table/feature-flags.tsx | 96 + lib/tasks/table/tasks-table-columns.tsx | 262 ++ lib/tasks/table/tasks-table-floating-bar.tsx | 354 +++ lib/tasks/table/tasks-table-toolbar-actions.tsx | 117 + lib/tasks/table/tasks-table.tsx | 197 ++ lib/tasks/table/update-task-sheet.tsx | 230 ++ lib/tasks/utils.ts | 80 + lib/tasks/validations.ts | 50 + lib/tbe/service.ts | 0 lib/tbe/table/comments-sheet.tsx | 334 +++ lib/tbe/table/feature-flags-provider.tsx | 108 + lib/tbe/table/file-dialog.tsx | 141 + lib/tbe/table/invite-vendors-dialog.tsx | 203 ++ lib/tbe/table/tbe-table-columns.tsx | 249 ++ lib/tbe/table/tbe-table-toolbar-actions.tsx | 60 + lib/tbe/table/tbe-table.tsx | 204 ++ lib/unstable-cache.ts | 19 + lib/users/repository.ts | 128 + lib/users/send-otp.ts | 71 + lib/users/service.ts | 413 +++ lib/users/table/assign-roles-dialog.tsx | 194 ++ lib/users/table/users-table-columns.tsx | 154 ++ lib/users/table/users-table-toolbar-actions.tsx | 61 + lib/users/table/users-table.tsx | 150 ++ lib/users/verifyOtp.ts | 28 + lib/users/verifyToken.ts | 38 + lib/utils.ts | 75 + lib/vendor-data/services.ts | 99 + lib/vendor-document-list/repository.ts | 44 + lib/vendor-document-list/service.ts | 284 ++ lib/vendor-document-list/table/add-doc-dialog.tsx | 299 +++ .../table/delete-docs-dialog.tsx | 231 ++ .../table/doc-table-column.tsx | 202 ++ .../table/doc-table-toolbar-actions.tsx | 66 + lib/vendor-document-list/table/doc-table.tsx | 110 + .../table/update-\bdoc-sheet.tsx" | 267 ++ lib/vendor-document-list/validations.ts | 33 + lib/vendor-document/repository.ts | 44 + lib/vendor-document/service.ts | 346 +++ lib/vendor-document/table/doc-table-column.tsx | 150 ++ .../table/doc-table-toolbar-actions.tsx | 57 + lib/vendor-document/table/doc-table.tsx | 124 + lib/vendor-document/validations.ts | 33 + lib/vendor-rfq-response/service.ts | 301 +++ lib/vendor-rfq-response/types.ts | 76 + .../vendor-rfq-table/ItemsDialog.tsx | 125 + .../vendor-rfq-table/attachment-rfq-sheet.tsx | 106 + .../vendor-rfq-table/comments-sheet.tsx | 415 +++ .../vendor-rfq-table/feature-flags-provider.tsx | 108 + .../vendor-rfq-table/rfqs-table-columns.tsx | 421 +++ .../rfqs-table-toolbar-actions.tsx | 40 + .../vendor-rfq-table/rfqs-table.tsx | 270 ++ .../vendor-tbe-table/comments-sheet.tsx | 334 +++ .../vendor-tbe-table/feature-flags-provider.tsx | 108 + .../vendor-tbe-table/tbe-table-columns.tsx | 317 +++ .../vendor-tbe-table/tbe-table.tsx | 162 ++ .../vendor-tbe-table/tbeFileHandler.tsx | 355 +++ lib/vendors/contacts-table/add-contact-dialog.tsx | 175 ++ .../contacts-table/contact-table-columns.tsx | 195 ++ .../contact-table-toolbar-actions.tsx | 106 + lib/vendors/contacts-table/contact-table.tsx | 87 + .../contacts-table/feature-flags-provider.tsx | 108 + lib/vendors/items-table/add-item-dialog.tsx | 289 ++ lib/vendors/items-table/feature-flags-provider.tsx | 108 + lib/vendors/items-table/item-table-columns.tsx | 197 ++ .../items-table/item-table-toolbar-actions.tsx | 106 + lib/vendors/items-table/item-table.tsx | 85 + lib/vendors/repository.ts | 282 ++ .../rfq-history-table/feature-flags-provider.tsx | 108 + .../rfq-history-table-columns.tsx | 223 ++ .../rfq-history-table-toolbar-actions.tsx | 136 + .../rfq-history-table/rfq-history-table.tsx | 156 ++ .../rfq-history-table/rfq-items-table-dialog.tsx | 98 + lib/vendors/service.ts | 1345 ++++++++++ lib/vendors/table/approve-vendor-dialog.tsx | 150 ++ lib/vendors/table/attachmentButton.tsx | 69 + lib/vendors/table/feature-flags-provider.tsx | 108 + lib/vendors/table/request-vendor-pg-dialog.tsx | 150 ++ lib/vendors/table/send-vendor-dialog.tsx | 150 ++ lib/vendors/table/update-vendor-sheet.tsx | 270 ++ lib/vendors/table/vendors-table-columns.tsx | 279 ++ lib/vendors/table/vendors-table-floating-bar.tsx | 241 ++ .../table/vendors-table-toolbar-actions.tsx | 97 + lib/vendors/table/vendors-table.tsx | 121 + lib/vendors/validations.ts | 341 +++ 245 files changed, 46874 insertions(+) create mode 100644 lib/admin-users/repository.ts create mode 100644 lib/admin-users/service.ts create mode 100644 lib/admin-users/table/add-ausers-dialog.tsx create mode 100644 lib/admin-users/table/ausers-table-columns.tsx create mode 100644 lib/admin-users/table/ausers-table-floating-bar.tsx create mode 100644 lib/admin-users/table/ausers-table-toolbar-actions.tsx create mode 100644 lib/admin-users/table/ausers-table.tsx create mode 100644 lib/admin-users/table/delete-ausers-dialog.tsx create mode 100644 lib/admin-users/table/update-auser-sheet.tsx create mode 100644 lib/admin-users/validations.ts create mode 100644 lib/compose-refs.ts create mode 100644 lib/constants.ts create mode 100644 lib/data-table.ts create mode 100644 lib/docuSign/docuSignFns.ts create mode 100644 lib/docuSign/jwtConfig/README.md create mode 100644 lib/docuSign/jwtConfig/jwtConfig.json create mode 100644 lib/docuSign/jwtConfig/private.key create mode 100644 lib/docuSign/types.ts create mode 100644 lib/downloadFile.ts create mode 100644 lib/equip-class/repository.ts create mode 100644 lib/equip-class/service.ts create mode 100644 lib/equip-class/table/equipClass-table-columns.tsx create mode 100644 lib/equip-class/table/equipClass-table-toolbar-actions.tsx create mode 100644 lib/equip-class/table/equipClass-table.tsx create mode 100644 lib/equip-class/table/feature-flags-provider.tsx create mode 100644 lib/equip-class/validation.ts create mode 100644 lib/export.ts create mode 100644 lib/export_all.ts create mode 100644 lib/filter-columns.ts create mode 100644 lib/fonts.ts create mode 100644 lib/form-list/repository.ts create mode 100644 lib/form-list/service.ts create mode 100644 lib/form-list/table/feature-flags-provider.tsx create mode 100644 lib/form-list/table/formLists-table-columns.tsx create mode 100644 lib/form-list/table/formLists-table-toolbar-actions.tsx create mode 100644 lib/form-list/table/formLists-table.tsx create mode 100644 lib/form-list/table/meta-sheet.tsx create mode 100644 lib/form-list/validation.ts create mode 100644 lib/forms/services.ts create mode 100644 lib/handle-error.ts create mode 100644 lib/id.ts create mode 100644 lib/items/repository.ts create mode 100644 lib/items/service.ts create mode 100644 lib/items/table/add-items-dialog.tsx create mode 100644 lib/items/table/delete-items-dialog.tsx create mode 100644 lib/items/table/feature-flags-provider.tsx create mode 100644 lib/items/table/feature-flags.tsx create mode 100644 lib/items/table/items-table-columns.tsx create mode 100644 lib/items/table/items-table-toolbar-actions.tsx create mode 100644 lib/items/table/items-table.tsx create mode 100644 lib/items/table/update-item-sheet.tsx create mode 100644 lib/items/validations.ts create mode 100644 lib/logger.ts create mode 100644 lib/mail/mailer.ts create mode 100644 lib/mail/sendEmail.ts create mode 100644 lib/mail/templates/admin-created.hbs create mode 100644 lib/mail/templates/admin-email-changed.hbs create mode 100644 lib/mail/templates/otp.hbs create mode 100644 lib/mail/templates/rfq-invite.hbs create mode 100644 lib/mail/templates/vendor-active.hbs create mode 100644 lib/mail/templates/vendor-pq-comment.hbs create mode 100644 lib/mail/templates/vendor-pq-status.hbs create mode 100644 lib/parsers.ts create mode 100644 lib/po/repository.ts create mode 100644 lib/po/service.ts create mode 100644 lib/po/service_r1.ts create mode 100644 lib/po/table/feature-flags-provider.tsx create mode 100644 lib/po/table/po-table-columns.tsx create mode 100644 lib/po/table/po-table-toolbar-actions.tsx create mode 100644 lib/po/table/po-table.tsx create mode 100644 lib/po/table/sign-request-dialog.tsx create mode 100644 lib/po/validations.ts create mode 100644 lib/pq/pq-review-table/feature-flags-provider.tsx create mode 100644 lib/pq/pq-review-table/vendors-table-columns.tsx create mode 100644 lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx create mode 100644 lib/pq/pq-review-table/vendors-table.tsx create mode 100644 lib/pq/repository.ts create mode 100644 lib/pq/service.ts create mode 100644 lib/pq/table/add-pq-dialog.tsx create mode 100644 lib/pq/table/delete-pqs-dialog.tsx create mode 100644 lib/pq/table/pq-table-column.tsx create mode 100644 lib/pq/table/pq-table-toolbar-actions.tsx create mode 100644 lib/pq/table/pq-table.tsx create mode 100644 lib/pq/table/update-pq-sheet.tsx create mode 100644 lib/pq/validations.ts create mode 100644 lib/rfqs/cbe-table/cbe-table-columns.tsx create mode 100644 lib/rfqs/cbe-table/cbe-table.tsx create mode 100644 lib/rfqs/cbe-table/feature-flags-provider.tsx create mode 100644 lib/rfqs/repository.ts create mode 100644 lib/rfqs/service.ts create mode 100644 lib/rfqs/table/BudgetaryRfqSelector.tsx create mode 100644 lib/rfqs/table/ItemsDialog.tsx create mode 100644 lib/rfqs/table/add-rfq-dialog.tsx create mode 100644 lib/rfqs/table/attachment-rfq-sheet.tsx create mode 100644 lib/rfqs/table/delete-rfqs-dialog.tsx create mode 100644 lib/rfqs/table/feature-flags-provider.tsx create mode 100644 lib/rfqs/table/feature-flags.tsx create mode 100644 lib/rfqs/table/rfqs-table-columns.tsx create mode 100644 lib/rfqs/table/rfqs-table-floating-bar.tsx create mode 100644 lib/rfqs/table/rfqs-table-toolbar-actions.tsx create mode 100644 lib/rfqs/table/rfqs-table.tsx create mode 100644 lib/rfqs/table/update-rfq-sheet.tsx create mode 100644 lib/rfqs/tbe-table/comments-sheet.tsx create mode 100644 lib/rfqs/tbe-table/feature-flags-provider.tsx create mode 100644 lib/rfqs/tbe-table/file-dialog.tsx create mode 100644 lib/rfqs/tbe-table/invite-vendors-dialog.tsx create mode 100644 lib/rfqs/tbe-table/tbe-table-columns.tsx create mode 100644 lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx create mode 100644 lib/rfqs/tbe-table/tbe-table.tsx create mode 100644 lib/rfqs/validations.ts create mode 100644 lib/rfqs/vendor-table/add-vendor-dialog.tsx create mode 100644 lib/rfqs/vendor-table/comments-sheet.tsx create mode 100644 lib/rfqs/vendor-table/feature-flags-provider.tsx create mode 100644 lib/rfqs/vendor-table/invite-vendors-dialog.tsx create mode 100644 lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx create mode 100644 lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx create mode 100644 lib/rfqs/vendor-table/vendors-table-columns.tsx create mode 100644 lib/rfqs/vendor-table/vendors-table-floating-bar.tsx create mode 100644 lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx create mode 100644 lib/rfqs/vendor-table/vendors-table.tsx create mode 100644 lib/roles/repository.ts create mode 100644 lib/roles/services.ts create mode 100644 lib/roles/table/add-role-dialog.tsx create mode 100644 lib/roles/table/assign-roles-sheet.tsx create mode 100644 lib/roles/table/delete-roles-dialog.tsx create mode 100644 lib/roles/table/role-table-toolbar-actions.tsx create mode 100644 lib/roles/table/roles-table-columns.tsx create mode 100644 lib/roles/table/roles-table.tsx create mode 100644 lib/roles/table/update-roles-sheet.tsx create mode 100644 lib/roles/userTable/assginedUsers-table-columns.tsx create mode 100644 lib/roles/userTable/assignedUsers-table.tsx create mode 100644 lib/roles/validations.ts create mode 100644 lib/storage.ts create mode 100644 lib/tag-numbering/repository.ts create mode 100644 lib/tag-numbering/service.ts create mode 100644 lib/tag-numbering/table/feature-flags-provider.tsx create mode 100644 lib/tag-numbering/table/meta-sheet.tsx create mode 100644 lib/tag-numbering/table/tagNumbering-table-columns.tsx create mode 100644 lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx create mode 100644 lib/tag-numbering/table/tagNumbering-table.tsx create mode 100644 lib/tag-numbering/validation.ts create mode 100644 lib/tags/form-mapping-service.ts create mode 100644 lib/tags/repository.ts create mode 100644 lib/tags/service.ts create mode 100644 lib/tags/table/add-tag-dialog copy.tsx create mode 100644 lib/tags/table/add-tag-dialog.tsx create mode 100644 lib/tags/table/delete-tags-dialog.tsx create mode 100644 lib/tags/table/feature-flags-provider.tsx create mode 100644 lib/tags/table/tag-table-column.tsx create mode 100644 lib/tags/table/tag-table.tsx create mode 100644 lib/tags/table/tags-export.tsx create mode 100644 lib/tags/table/tags-table-floating-bar.tsx create mode 100644 lib/tags/table/tags-table-toolbar-actions.tsx create mode 100644 lib/tags/table/update-tag-sheet.tsx create mode 100644 lib/tags/validations.ts create mode 100644 lib/tasks/repository.ts create mode 100644 lib/tasks/service.ts create mode 100644 lib/tasks/table/add-tasks-dialog.tsx create mode 100644 lib/tasks/table/delete-tasks-dialog.tsx create mode 100644 lib/tasks/table/feature-flags-provider.tsx create mode 100644 lib/tasks/table/feature-flags.tsx create mode 100644 lib/tasks/table/tasks-table-columns.tsx create mode 100644 lib/tasks/table/tasks-table-floating-bar.tsx create mode 100644 lib/tasks/table/tasks-table-toolbar-actions.tsx create mode 100644 lib/tasks/table/tasks-table.tsx create mode 100644 lib/tasks/table/update-task-sheet.tsx create mode 100644 lib/tasks/utils.ts create mode 100644 lib/tasks/validations.ts create mode 100644 lib/tbe/service.ts create mode 100644 lib/tbe/table/comments-sheet.tsx create mode 100644 lib/tbe/table/feature-flags-provider.tsx create mode 100644 lib/tbe/table/file-dialog.tsx create mode 100644 lib/tbe/table/invite-vendors-dialog.tsx create mode 100644 lib/tbe/table/tbe-table-columns.tsx create mode 100644 lib/tbe/table/tbe-table-toolbar-actions.tsx create mode 100644 lib/tbe/table/tbe-table.tsx create mode 100644 lib/unstable-cache.ts create mode 100644 lib/users/repository.ts create mode 100644 lib/users/send-otp.ts create mode 100644 lib/users/service.ts create mode 100644 lib/users/table/assign-roles-dialog.tsx create mode 100644 lib/users/table/users-table-columns.tsx create mode 100644 lib/users/table/users-table-toolbar-actions.tsx create mode 100644 lib/users/table/users-table.tsx create mode 100644 lib/users/verifyOtp.ts create mode 100644 lib/users/verifyToken.ts create mode 100644 lib/utils.ts create mode 100644 lib/vendor-data/services.ts create mode 100644 lib/vendor-document-list/repository.ts create mode 100644 lib/vendor-document-list/service.ts create mode 100644 lib/vendor-document-list/table/add-doc-dialog.tsx create mode 100644 lib/vendor-document-list/table/delete-docs-dialog.tsx create mode 100644 lib/vendor-document-list/table/doc-table-column.tsx create mode 100644 lib/vendor-document-list/table/doc-table-toolbar-actions.tsx create mode 100644 lib/vendor-document-list/table/doc-table.tsx create mode 100644 "lib/vendor-document-list/table/update-\bdoc-sheet.tsx" create mode 100644 lib/vendor-document-list/validations.ts create mode 100644 lib/vendor-document/repository.ts create mode 100644 lib/vendor-document/service.ts create mode 100644 lib/vendor-document/table/doc-table-column.tsx create mode 100644 lib/vendor-document/table/doc-table-toolbar-actions.tsx create mode 100644 lib/vendor-document/table/doc-table.tsx create mode 100644 lib/vendor-document/validations.ts create mode 100644 lib/vendor-rfq-response/service.ts create mode 100644 lib/vendor-rfq-response/types.ts create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx create mode 100644 lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx create mode 100644 lib/vendors/contacts-table/add-contact-dialog.tsx create mode 100644 lib/vendors/contacts-table/contact-table-columns.tsx create mode 100644 lib/vendors/contacts-table/contact-table-toolbar-actions.tsx create mode 100644 lib/vendors/contacts-table/contact-table.tsx create mode 100644 lib/vendors/contacts-table/feature-flags-provider.tsx create mode 100644 lib/vendors/items-table/add-item-dialog.tsx create mode 100644 lib/vendors/items-table/feature-flags-provider.tsx create mode 100644 lib/vendors/items-table/item-table-columns.tsx create mode 100644 lib/vendors/items-table/item-table-toolbar-actions.tsx create mode 100644 lib/vendors/items-table/item-table.tsx create mode 100644 lib/vendors/repository.ts create mode 100644 lib/vendors/rfq-history-table/feature-flags-provider.tsx create mode 100644 lib/vendors/rfq-history-table/rfq-history-table-columns.tsx create mode 100644 lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx create mode 100644 lib/vendors/rfq-history-table/rfq-history-table.tsx create mode 100644 lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx create mode 100644 lib/vendors/service.ts create mode 100644 lib/vendors/table/approve-vendor-dialog.tsx create mode 100644 lib/vendors/table/attachmentButton.tsx create mode 100644 lib/vendors/table/feature-flags-provider.tsx create mode 100644 lib/vendors/table/request-vendor-pg-dialog.tsx create mode 100644 lib/vendors/table/send-vendor-dialog.tsx create mode 100644 lib/vendors/table/update-vendor-sheet.tsx create mode 100644 lib/vendors/table/vendors-table-columns.tsx create mode 100644 lib/vendors/table/vendors-table-floating-bar.tsx create mode 100644 lib/vendors/table/vendors-table-toolbar-actions.tsx create mode 100644 lib/vendors/table/vendors-table.tsx create mode 100644 lib/vendors/validations.ts (limited to 'lib') diff --git a/lib/admin-users/repository.ts b/lib/admin-users/repository.ts new file mode 100644 index 00000000..aff2da28 --- /dev/null +++ b/lib/admin-users/repository.ts @@ -0,0 +1,171 @@ +import db from "@/db/db"; +import { users, userRoles,userView,roles, type User, type UserRole, type UserView, Role } from "@/db/schema/users"; +import { companies, type Company } from "@/db/schema/companies"; +import { + eq, + inArray, + asc, + desc, + and, + count, + gt, + sql, + SQL, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { Vendor, vendors } from "@/db/schema/vendors"; + +// ============================================================ +// 타입 +// ============================================================ + +export type NewUser = typeof users.$inferInsert; // User insert 시 필요한 타입 +export type NewUserRole = typeof userRoles.$inferInsert; // UserRole insert 시 필요한 타입 +export type NewCompany = typeof companies.$inferInsert; // Company insert 시 필요한 타입 + + + +export async function selectUsersWithCompanyAndRoles( + tx: PgTransaction, + params: { + where?: any + orderBy?: (ReturnType | ReturnType)[] + offset?: number + limit?: number + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params + + // 1) 쿼리 빌더 생성 + const queryBuilder = tx + .select() + .from(userView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit) + + const rows = await queryBuilder + return rows +} + + +/** 총 개수 count */ +export async function countUsers( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(userView).where(where); + return res[0]?.count ?? 0; +} + +export async function groupByCompany( + tx: PgTransaction, +) { + return tx + .select({ + companyId: users.companyId, + count: count(), + }) + .from(users) + .groupBy(users.companyId) + .having(gt(count(), 0)); +} + +export async function groupByRole(tx: PgTransaction) { + return tx + .select({ + roleId: userRoles.roleId, + count: sql`COUNT(*)`.as("count"), + }) + .from(users) + .leftJoin(userRoles, eq(userRoles.userId, users.id)) + .leftJoin(roles, eq(roles.id, userRoles.roleId)) + .groupBy(userRoles.roleId, roles.id, roles.name) + .having(gt(sql`COUNT(*)` /* 또는 count()와 동일 */, 0)); +} + +export async function insertUser( + tx: PgTransaction, + data: NewUser +) { + return tx.insert(users).values(data).returning(); +} + +export async function insertUserRole( + tx: PgTransaction, + data: NewUserRole +) { + return tx.insert(userRoles).values(data).returning(); +} + +export async function updateUser( + tx: PgTransaction, + userId: number, + data: Partial +) { + return tx + .update(users) + .set(data) + .where(eq(users.id, userId)) + .returning(); +} + +/** 복수 업데이트 */ +export async function updateUsers( + tx: PgTransaction, +ids: number[], +data: Partial +) { +return tx + .update(users) + .set(data) + .where(inArray(users.id, ids)) + .returning({ companyId: users.companyId }); +} + +export async function deleteRolesByUserId( + tx: PgTransaction, + userId: number +) { + return tx.delete(userRoles).where(eq(userRoles.userId, userId)); +} + + +export async function deleteRolesByUserIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(userRoles).where(inArray(userRoles.userId, ids)); +} + +export async function deleteUserById( + tx: PgTransaction, + userId: number +) { + return tx.delete(users).where(eq(users.id, userId)); +} + + +export async function deleteUsersByIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(users).where(inArray(users.id, ids)); +} + +export async function findAllCompanies(): Promise { + return db.select().from(vendors).orderBy(asc(vendors.vendorName)); +} + +export async function findAllRoles(): Promise { + return db.select().from(roles).where(eq(roles.domain ,'partners')).orderBy(asc(roles.name)); +} + +export const getUserById = async (id: number): Promise => { + const userFouned = await db.select().from(userView).where(eq(userView.user_id, id)).execute(); + if (userFouned.length === 0) return null; + + const user = userFouned[0]; + return user +}; diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts new file mode 100644 index 00000000..5d738d38 --- /dev/null +++ b/lib/admin-users/service.ts @@ -0,0 +1,531 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import logger from '@/lib/logger'; + +import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 +import { type Company } from "@/db/schema/companies"; // User 테이블 +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; + +// 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 +import { + selectUsersWithCompanyAndRoles, + countUsers, + insertUser, + insertUserRole, + updateUser, deleteRolesByUserId, deleteRolesByUserIds, + deleteUserById, + deleteUsersByIds, + groupByCompany, + groupByRole, + findAllCompanies, getUserById, updateUsers, + findAllRoles +} from "./repository"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +// types +import type { CreateUserSchema, UpdateUserSchema, GetUsersSchema } from "./validations"; + +import { sendEmail } from "@/lib//mail/sendEmail"; +import { Vendor } from "@/db/schema/vendors"; + +/** + * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ + + +export async function getUsers(input: GetUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); + if (!hasDomainFilter) { + domainWhere = eq(userView.user_domain, "partners"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + +export async function findUserById(id: number) { + try { + logger.info({ id }, 'Fetching user by ID'); + const user = await getUserById(id); + if (!user) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +/** + * User 생성 + * 필요 시 companyId, roles, etc. 함께 처리 + */ +// export async function createUser(input: CreateUserSchema & { language?: string }) { +// unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) +// try { +// const userLang = input.language || "en"; // 클라이언트가 안 주면 기본 "en" +// // 예시 subject 분기 +// const subject = +// userLang === "ko" +// ? "[eVCP] 어드민 계정이 생성되었습니다." +// : "[eVCP] Admin Account Created"; + +// const loginUrl = +// userLang === "ko" +// ? "http://3.36.56.124:3000/ko/login" +// : "http://3.36.56.124:3000/en/login"; + +// // 실제 sendEmail +// await sendEmail({ +// to: input.email, +// subject, +// template: "admin-created", +// context: { +// name: input.name, +// loginUrl, // 위에서 분기한 URL +// language: userLang, // 템플릿에서 {{t ... lng=language}} 처럼 쓸 수도 +// }, +// }); + +// await db.transaction(async (tx) => { +// // insertUser는 단건 생성 +// const [newUser] = await insertUser(tx, { +// name: input.name, +// email: input.email, +// domain: input.domain, +// companyId: input.companyId ?? null, +// // 기타 필요한 필드 +// }); + +// // 만약 roles를 함께 생성하려면, +// await insertUserRole(tx, { userId: newUser.id, roleId: Number(r) }); +// } +// }); + +// // 캐시 무효화 +// revalidateTag("users"); +// revalidateTag("user-company-counts"); + + + +// return { data: null, error: null }; +// } catch (err) { +// return { data: null, error: getErrorMessage(err) }; +// } +// } + +export async function createAdminUser(input: CreateUserSchema & { language?: string }) { + unstable_noStore(); // Next.js 캐싱 방지 + + try { + // 예) 관리자 메일 알림 로직 + // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신 + // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다. + const userLang = input.language || "en"; + const subject = userLang === "ko" + ? "[eVCP] 어드민 계정이 생성되었습니다." + : "[eVCP] Admin Account Created"; + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + + const loginUrl = userLang === "ko" + ? `${baseUrl}/ko/partners` + : `${baseUrl}/en/partners`; + + await sendEmail({ + to: input.email, + subject, + template: "admin-created", // 예: nodemailer + handlebars 등 + context: { + name: input.name, + loginUrl, + language: userLang, + }, + }); + + // 트랜잭션 시작 + await db.transaction(async (tx) => { + // 1. 먼저 roles 테이블에서 name = "Vendor Admin" AND domain = input.domain 인 것을 찾는다. + let [vendorAdminRole] = await tx + .select() + .from(roles) + .where( + and( + eq(roles.name, "Vendor Admin"), + eq(roles.domain, input.domain), + eq(roles.companyId, input.companyId as number), + ) + ) + .limit(1); + + // 2. 만약 없다면, 새롭게 생성한다. + if (!vendorAdminRole) { + // companyId나 description 등은 필요에 따라 조정 + const insertedRoles = await tx + .insert(roles) + .values({ + name: "Vendor Admin", + domain: input.domain, + companyId: input.companyId ?? null, + description: "Auto created Vendor Admin role", + }) + .returning(); + vendorAdminRole = insertedRoles[0]; // 방금 insert한 row + } + + // 3. 유저 생성 + const [newUser] = await insertUser(tx, { + name: input.name, + email: input.email, + domain: input.domain, + companyId: input.companyId ?? null, + // 기타 필요한 필드 추가 + }); + + // 4. Vendor Admin role을 user_roles 에 할당 (반복문 없이 단일 insert) + await insertUserRole(tx, { + userId: newUser.id, + roleId: vendorAdminRole.id, // Number()로 캐스팅할 필요 없이 정수로 관리한다고 가정 + }); + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 회사별 유저 개수 groupBy + */ +export async function getUserCountGroupByCompany() { + return unstable_cache( + async () => { + try { + // 예: { [companyId: number]: number } + const result = await db.transaction(async (tx) => { + const rows = await groupByCompany(tx); + // groupByCompany(tx): SELECT companyId, COUNT(*) FROM users GROUP BY companyId HAVING COUNT(*) > 0 + // 예: [{ companyId: 1, count: 10 }, { companyId: 2, count: 3 }, ...] + + // reduce해서 {1: 10, 2: 3, ...} 형태로 만들거나 그대로 반환할 수 있음 + const obj: Record = {}; + for (const row of rows) { + if (row.companyId !== null) { + obj[row.companyId] = row.count; + } else { + // companyId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + return result; + } catch (err) { + return {}; + } + }, + ["user-company-counts"], + { + revalidate: 3600, + } + )(); +} + +/** + * 롤별 유저 개수 groupBy + */ +export async function getUserCountGroupByRole() { + return unstable_cache( + async () => { + try { + const result = await db.transaction(async (tx) => { + const rows = await groupByRole(tx); + + const obj: Record = {}; + for (const row of rows) { + if (row.roleId !== null) { + obj[row.roleId] = row.count; + } else { + // roleId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + + // 여기서 result를 반환해 줘야 함! + return result; + } catch (err) { + console.error("getUserCountGroupByRole error:", err); + return {}; + } + }, + ["user-role-counts"], + { + revalidate: 3600, + } + )(); +} +/** + * 단건 업데이트 + */ +export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) { + unstable_noStore(); + + try { + + const oldUser = await getUserById(input.id) + const oldEmail = oldUser?.user_email ?? null; + + const data = await db.transaction(async (tx) => { + // 1) 먼저 User 테이블 업데이트 + const [res] = await updateUser(tx, input.id, { + name: input.name, + companyId: input.companyId, + email: input.email, + }); + + // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입 + if (input.roles) { + // 기존 roles 삭제 + await deleteRolesByUserId(tx, input.id); + + // 새 roles 삽입 + for (const r of input.roles) { + await insertUserRole(tx, { + userId: input.id, + roleId: Number(r), + }); + } + } + + return res; + }); + + // 3) 캐시 무효화 + revalidateTag("users"); + + // 4) 이메일이 변경되었고, roles 중에 "admin"이 있다면 → 메일 발송 + const isEmailChanged = oldEmail && input.email && oldEmail !== input.email; + const hasAdminRole = input.roles?.includes("admin") ?? false; + + if (isEmailChanged && hasAdminRole && input.email) { + await sendEmail({ + to: input.email, + subject: "[EVCP] Admin Email Changed", + template: "admin-email-changed", + context: { + name: input.name, + oldEmail, + newEmail: input.email, + language: input.language ?? "en", + }, + }); + } + + // 예: companyId 변경 시 회사별 count도 다시 계산해야 하는 경우 + if (data.companyId === input.companyId) { + revalidateTag("user-company-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +/** 복수 업데이트 */ +export async function modifiUsers(input: { + ids: number[]; // 업데이트 대상 유저 ID 배열 + companyId?: User["companyId"]; // 회사 ID (있으면 업데이트) + roles?: UserView["roles"]; // 새 roles 배열 (있으면 업데이트) +}) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + + try { + await db.transaction(async (tx) => { + // 1) 회사 정보 업데이트 + if (typeof input.companyId !== "undefined") { + // companyId가 주어졌으면, 해당 사용자들의 companyId 변경 + await updateUsers(tx, input.ids, { companyId: input.companyId }) + } + + // 2) roles 업데이트 + // (있으면 기존 roles 삭제 → 새 roles 삽입) + if (Array.isArray(input.roles)) { + // (a) 기존 roles 전부 삭제 + await deleteRolesByUserIds(tx, input.ids) + + // (b) 새 roles 삽입 + for (const userId of input.ids) { + for (const r of input.roles) { + await insertUserRole(tx, { + userId, + roleId: Number(r), + }) + } + } + } + }) + + // 캐시 무효화 + revalidateTag("users") + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} +/** + * 단건 삭제 + */ +export async function removeUser(input: { id: number }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // 유저 삭제 + await deleteRolesByUserId(tx, input.id); + await deleteUserById(tx, input.id); + // roles, otps 등도 함께 삭제해야 하면 여기서 처리 + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 복수 삭제 + */ +export async function removeUsers(input: { ids: number[] }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // user_roles도 있으면 먼저 삭제해야 할 수 있음 + await deleteRolesByUserIds(tx, input.ids); + await deleteUsersByIds(tx, input.ids); + }); + + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getAllCompanies(): Promise { + try { + return await findAllCompanies(); // Company[] + } catch (err) { + throw new Error("Failed to get companies"); + } +} + +export async function getAllRoles(): Promise { + try { + return await findAllRoles(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} + +/** + * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수 + * @param email 확인할 이메일 + * @returns boolean - 존재하면 true, 없으면 false + */ +export async function checkEmailExists(email: string): Promise { + const result = await db + .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만 + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return result.length > 0; // 1건 이상 있으면 true +} diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx new file mode 100644 index 00000000..dd29c190 --- /dev/null +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -0,0 +1,348 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Role, userRoles } from "@/db/schema/users" +import { createUserSchema, type CreateUserSchema } from "@/lib/admin-users/validations" +import { createAdminUser, getAllCompanies, getAllRoles } from "@/lib/admin-users/service" +import { type Company } from "@/db/schema/companies" +import { MultiSelect } from "@/components/ui/multi-select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { Vendor } from "@/db/schema/vendors" + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function AddUserDialog() { + const [open, setOpen] = React.useState(false) + const [companies, setCompanies] = React.useState([]) // 회사 목록 + const [roles, setRoles] = React.useState([]) + const [isAddPending, startAddTransition] = React.useTransition() + + + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + + getAllRoles().then((res) => { + setRoles(res) + }) + }, []) + + // react-hook-form 세팅 + const form = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { + name: "", + email: "", + companyId: null, + language:'en', + // roles는 array, 여기서는 단일 선택 시 [role]로 담음 + roles: [], + domain:'partners' + // domain, etc. 필요하다면 추가 + }, + }) + + + async function onSubmit(data: CreateUserSchema & { language?: string }) { + data.domain = "partners" + + // 만약 단일 Select로 role을 정했다면, data.roles = ["manager"] 이런 식 + startAddTransition(async ()=> { + const result = await createAdminUser(data) + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + form.reset() + setOpen(false) + toast.success("User added") + }) + + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + + {/* 모달을 열기 위한 버튼 */} + + + + + + + Create New User + + 새 User 정보를 입력하고 Create 버튼을 누르세요. + + + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} +
+ +
+ {/* 사용자 이름 */} + ( + + User Name + + + + + + )} + /> + + {/* 이메일 */} + ( + + Email + + + + + + )} + /> + + {/* 회사 선택 (companyId) */} + { + // 현재 선택된 회사 ID (number) → 문자열 + const valueString = field.value ? String(field.value) : "" + + + // 현재 선택된 회사 + const selectedCompany = companies.find( + (c) => String(c.id) === valueString + ) + + const selectedCompanyLabel = selectedCompany && `${selectedCompany.vendorName} ${selectedCompany.taxId}` + + const [popoverOpen, setPopoverOpen] = React.useState(false) + + + return ( + + Company + + + + + + + + + + + No company found. + + {companies.map((comp) => { + // string(comp.id) + const compIdStr = String(comp.id) + const label = `${comp.vendorName}${comp.taxId}` + const label2 = `${comp.vendorName} ${comp.taxId}` + return ( + { + // 회사 ID를 number로 + field.onChange(Number(comp.id)) + setPopoverOpen(false) + + }} + > + {label2} + + + ) + })} + + + + + + + + + ) + }} + /> + {/* Role (Vendor Admin) - 읽기 전용 */} + ( + + Role + {/* UI에선 그냥 Vendor Admin이라고 표시만 (disabled) */} + + + + + + )} + /> + + {/* language Select */} + ( + + Language + + + + + + )} + /> + +
+ + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/admin-users/table/ausers-table-columns.tsx b/lib/admin-users/table/ausers-table-columns.tsx new file mode 100644 index 00000000..38281c7e --- /dev/null +++ b/lib/admin-users/table/ausers-table-columns.tsx @@ -0,0 +1,228 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { userRoles, type UserView } from "@/db/schema/users" + +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 { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { userColumnsConfig } from "@/config/userColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + Edit + + + {/* + Roles + + ({ + value: role, + label: role, + }))} + value={row.original.roles} + onValueChange={(value) => { + startUpdateTransition(() => { + + toast.promise( + modifiUser({ + id: row.original.user_id, + roles: value as ("admin"|"normal")[], + }), + { + loading: "Updating...", + success: "Roles updated", + error: (err) => getErrorMessage(err), + } + ); + }); + }} + + /> + + */} + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + userColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "roles") { + const roleValues = row.original.roles; + return ( +
+ {roleValues.map((v) => ( + + {v} + + ))} +
+ ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 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, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +} \ No newline at end of file diff --git a/lib/admin-users/table/ausers-table-floating-bar.tsx b/lib/admin-users/table/ausers-table-floating-bar.tsx new file mode 100644 index 00000000..ae950252 --- /dev/null +++ b/lib/admin-users/table/ausers-table-floating-bar.tsx @@ -0,0 +1,389 @@ +"use client" + +import * as React from "react" +import { userRoles, users, UserView, type User } from "@/db/schema/users" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, Check +} 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 { modifiUsers, getAllCompanies, removeUsers } from "@/lib//admin-users/service" +import { type Company } from "@/db/schema/companies" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { MultiSelect } from "@/components/ui/multi-select" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +interface AusersTableFloatingBarProps { + table: Table +} + + +export function AusersTableFloatingBar({ table }: AusersTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-company" | "update-roles" | "export" | "delete" + >() + const [companies, setCompanies] = React.useState([]) // 회사 목록 + + // 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]) + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + }, []) + + const [popoverOpen, setPopoverOpen] = React.useState(false) + const [rolesPopoverOpen, setRolesPopoverOpen] = React.useState(false) + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeUsers({ + ids: rows.map((row) => row.original.user_id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) "회사 업데이트"에서 회사 선택 시 → Confirm Dialog + function handleSelectCompany(comp: Company) { + setAction("update-company") + setPopoverOpen(false) + + // Confirm Dialog에 전달할 내용 + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} to "${comp.name}"?`, + description: `TaxID: ${comp.taxID}. This action will overwrite their current company.`, + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + companyId: comp.id, + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) "역할 업데이트" MultiSelect 후 → Confirm Dialog + function handleSelectRoles(newRoles: string[]) { + setAction("update-roles") + setRolesPopoverOpen(false) + + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} with roles: ${newRoles.join(", ")}?`, + description: "This action will override their current roles.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + roles: newRoles as ("admin" | "normal")[], + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + +
+
+
+
+ + {rows.length} selected + + + + + + + +

Clear selection

+ + Esc + +
+
+
+ +
+ + + + + + + + + +

Update company

+
+
+ + + + + + No company found. + + {companies.map((comp) => { + const label = `${comp.name} (${comp.taxID})` + return ( + handleSelectCompany(comp)} + > + {label} + + ) + })} + + + + +
+ + + + + + + + + + +

Update roles

+
+
+ + { + handleSelectRoles(newRoles) + }} + /> + + +
+ + + + + + + +

Export users

+
+
+ + + + + + +

Delete users

+
+
+
+
+
+
+ + {/* 공용 Confirm Dialog */} + +
+ ) +} diff --git a/lib/admin-users/table/ausers-table-toolbar-actions.tsx b/lib/admin-users/table/ausers-table-toolbar-actions.tsx new file mode 100644 index 00000000..5472c3ac --- /dev/null +++ b/lib/admin-users/table/ausers-table-toolbar-actions.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +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 { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AddUserDialog } from "./add-ausers-dialog" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { type UserView } from "@/db/schema/users" + +interface AdmUserTableToolbarActionsProps { + table: Table +} + +export function AdmUserTableToolbarActions({ table }: AdmUserTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 요소를 클릭 + fileInputRef.current?.click() + } + + return ( +
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + + + {/** 3) Import 버튼 (파일 업로드) */} + + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx new file mode 100644 index 00000000..ed575e75 --- /dev/null +++ b/lib/admin-users/table/ausers-table.tsx @@ -0,0 +1,180 @@ +"use client" + +import * as React from "react" +import { userRoles , type UserView} from "@/db/schema/users" +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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getUserCountGroupByCompany, + getUserCountGroupByRole, + getUsers, getAllCompanies, + getAllRoles +} from "@/lib//admin-users/service" +import { getColumns } from "./ausers-table-columns" +import { AdmUserTableToolbarActions } from "./ausers-table-toolbar-actions" +import { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AusersTableFloatingBar } from "./ausers-table-floating-bar" +import { UpdateAuserSheet } from "./update-auser-sheet" + +interface UsersTableProps { + promises: Promise< + [ + Awaited>, + Record, + Record, + Awaited>, + Awaited> + ] + > +} +type RoleCounts = Record + +export function AdmUserTable({ promises }: UsersTableProps) { + + const [{ data, pageCount }, companyCounts,roleCountsRaw, companies, roles] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const roleCounts = roleCountsRaw as RoleCounts + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField[] = [ + { + id: "user_email", + label: "Email", + placeholder: "Filter email...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "user_name", + label: "User Name", + type: "text", + }, + { + id: "user_email", + label: "Email", + type: "text", + }, + { + id: "company_name", + label: "Company", + type: "multi-select", + options: companies.map((comp) => ({ + label: comp.vendorName, + value: comp.vendorName, + count: companyCounts[comp.id] + })), + }, + + { + id: "roles", + label: "Roles", + type: "multi-select", + options: roles.map((role) => { + return { + label: toSentenceCase(role.name), + value: role.id, + count: roleCounts[role.id], // 이 값이 undefined인지 확인 + }; + }), + }, + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.user_id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + } + + > + + + + + + + setRowAction(null)} + users={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + setRowAction(null)} + user={rowAction?.row.original ?? null} + /> + + + ) +} diff --git a/lib/admin-users/table/delete-ausers-dialog.tsx b/lib/admin-users/table/delete-ausers-dialog.tsx new file mode 100644 index 00000000..0699bb95 --- /dev/null +++ b/lib/admin-users/table/delete-ausers-dialog.tsx @@ -0,0 +1,149 @@ +"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 { removeUsers } from "@/lib//admin-users/service" +import { type UserView } from "@/db/schema/users" + +interface DeleteUsersDialogProps + extends React.ComponentPropsWithoutRef { + users: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteUsersDialog({ + users, + showTrigger = true, + onSuccess, + ...props +}: DeleteUsersDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeUsers({ + ids: users.map((user) => Number(user.user_id)), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Users deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your{" "} + {users.length} + {users.length === 1 ? " user" : " users"} from our servers. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your{" "} + {users.length} + {users.length === 1 ? " user" : " users"} from our servers. + + + + + + + + + + + ) +} diff --git a/lib/admin-users/table/update-auser-sheet.tsx b/lib/admin-users/table/update-auser-sheet.tsx new file mode 100644 index 00000000..ddf1f932 --- /dev/null +++ b/lib/admin-users/table/update-auser-sheet.tsx @@ -0,0 +1,225 @@ +"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 { + 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 { Input } from "@/components/ui/input" +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + SelectGroup, +} from "@/components/ui/select" +// import your MultiSelect or other role selection +import { MultiSelect } from "@/components/ui/multi-select" + +import { userRoles, type UserView } from "@/db/schema/users" +import { updateUserSchema, type UpdateUserSchema } from "@/lib/admin-users/validations" +import { modifiUser } from "@/lib/admin-users/service" + +export interface UpdateAuserSheetProps + extends React.ComponentPropsWithRef { + user: UserView | null +} + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // 1) RHF 설정 + const form = useForm({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + name: user?.user_name ?? "", + email: user?.user_email ?? "", + companyId: user?.company_id ?? null, + roles: user?.roles ?? [], + language:'en', + }, + }) + + // 2) user prop 바뀔 때마다 form.reset + React.useEffect(() => { + if (user) { + form.reset({ + name: user.user_name, + email: user.user_email, + companyId: user.company_id, + roles: user.roles, + }) + } + }, [user, form]) + + + // 3) onSubmit + async function onSubmit(input: UpdateUserSchema & { language?: string }) { + startUpdateTransition(async () => { + if (!user) return + + const { error } = await modifiUser({ + id: user.user_id, // user.userId + ...input, + }) + + if (error) { + toast.error(error) + return + } + + // 성공 시 + form.reset() + props.onOpenChange?.(false) + toast.success("User updated successfully!") + }) + } + + return ( + + + + Update user + + Update the user details and save the changes + + + + {/* 4) RHF Form */} +
+ + {/* name */} + ( + + User Name + + + + + + )} + /> + + {/* email */} + ( + + Email + + + + + + )} + /> + + {/* roles */} + ( + + Roles + + field.onChange(vals)} + /> + + + + )} + /> + + ( + + Language + + + + + + )} + /> + + {/* 5) Footer: Cancel, Save */} + + + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/admin-users/validations.ts b/lib/admin-users/validations.ts new file mode 100644 index 00000000..e505067d --- /dev/null +++ b/lib/admin-users/validations.ts @@ -0,0 +1,65 @@ +import { userRoles, users, type UserView } from "@/db/schema/users"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { checkEmailExists } from "./service"; + + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "created_at", desc: true }, + ]), + email: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createUserSchema = z.object({ + email: z + .string() + .email() + .refine( + async (email) => { + // 1) DB 조회해서 이미 같은 email이 있으면 false 반환 + const isUsed = await checkEmailExists(email); + return !isUsed; + }, + { + message: "This email is already in use", + } + ), + name: z.string().min(1), // 최소 길이 1 + domain: z.enum(users.domain.enumValues), // "evcp" | "partners" + companyId: z.number().nullable().optional(), // number | null | undefined + roles:z.array(z.string()).min(1, "At least one role must be selected"), + language: z.enum(["ko", "en"]).optional(), + +}); + +export const updateUserSchema = z.object({ + name: z.string().optional(), + email: z.string().email().optional(), + domain: z.enum(users.domain.enumValues).optional(), + companyId: z.number().nullable().optional(), + roles: z.array(z.string()).optional(), + language: z.enum(["ko", "en"]).optional(), + +}); +export type GetUsersSchema = Awaited> +export type CreateUserSchema = z.infer +export type UpdateUserSchema = z.infer diff --git a/lib/compose-refs.ts b/lib/compose-refs.ts new file mode 100644 index 00000000..bed48a40 --- /dev/null +++ b/lib/compose-refs.ts @@ -0,0 +1,38 @@ +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx + */ + +import * as React from "react" + +type PossibleRef = React.Ref | undefined + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef(ref: PossibleRef, value: T) { + if (typeof ref === "function") { + ref(value) + } else if (ref !== null && ref !== undefined) { + ;(ref as React.MutableRefObject).current = value + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs(...refs: PossibleRef[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)) +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs(...refs: PossibleRef[]) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return React.useCallback(composeRefs(...refs), refs) +} + +export { composeRefs, useComposedRefs } diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 00000000..c95834ad --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,3 @@ +export const unknownError = "An unknown error occurred. Please try again later." + +export const databasePrefix = "shadcn" diff --git a/lib/data-table.ts b/lib/data-table.ts new file mode 100644 index 00000000..4fed7b9b --- /dev/null +++ b/lib/data-table.ts @@ -0,0 +1,181 @@ +import type { ColumnType, Filter, FilterOperator, } from "@/types/table" +import { type Column } from "@tanstack/react-table" + +import { dataTableConfig } from "@/config/data-table" +import { FilterFn, Row } from "@tanstack/react-table" + +/** + * Generate common pinning styles for a table column. + * + * This function calculates and returns CSS properties for pinned columns in a data table. + * It handles both left and right pinning, applying appropriate styles for positioning, + * shadows, and z-index. The function also considers whether the column is the last left-pinned + * or first right-pinned column to apply specific shadow effects. + * + * @param options - The options for generating pinning styles. + * @param options.column - The column object for which to generate styles. + * @param options.withBorder - Whether to show a box shadow between pinned and scrollable columns. + * @returns A React.CSSProperties object containing the calculated styles. + */ +export function getCommonPinningStyles({ + column, + withBorder = false, +}: { + column: Column + /** + * Show box shadow between pinned and scrollable columns. + * @default false + */ + withBorder?: boolean +}): React.CSSProperties { + const isPinned = column.getIsPinned() + const isLastLeftPinnedColumn = + isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinnedColumn = + isPinned === "right" && column.getIsFirstColumn("right") + + return { + boxShadow: withBorder + ? isLastLeftPinnedColumn + ? "-4px 0 4px -4px hsl(var(--border)) inset" + : isFirstRightPinnedColumn + ? "4px 0 4px -4px hsl(var(--border)) inset" + : undefined + : undefined, + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + opacity: isPinned ? 0.97 : 1, + position: isPinned ? "sticky" : "relative", + background: isPinned ? "hsl(var(--background))" : "hsl(var(--background))", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + } +} + +/** + * Determine the default filter operator for a given column type. + * + * This function returns the most appropriate default filter operator based on the + * column's data type. For text columns, it returns 'iLike' (case-insensitive like), + * while for all other types, it returns 'eq' (equality). + * + * @param columnType - The type of the column (e.g., 'text', 'number', 'date', etc.). + * @returns The default FilterOperator for the given column type. + */ +export function getDefaultFilterOperator( + columnType: ColumnType +): FilterOperator { + if (columnType === "text") { + return "iLike" + } + + return "eq" +} + +/** + * Retrieve the list of applicable filter operators for a given column type. + * + * This function returns an array of filter operators that are relevant and applicable + * to the specified column type. It uses a predefined mapping of column types to + * operator lists, falling back to text operators if an unknown column type is provided. + * + * @param columnType - The type of the column for which to get filter operators. + * @returns An array of objects, each containing a label and value for a filter operator. + */ +export function getFilterOperators(columnType: ColumnType) { + const operatorMap: Record< + ColumnType, + { label: string; value: FilterOperator }[] + > = { + text: dataTableConfig.textOperators, + number: dataTableConfig.numericOperators, + select: dataTableConfig.selectOperators, + "multi-select": dataTableConfig.selectOperators, + boolean: dataTableConfig.booleanOperators, + date: dataTableConfig.dateOperators, + } + + return operatorMap[columnType] ?? dataTableConfig.textOperators +} + +/** + * Filters out invalid or empty filters from an array of filters. + * + * This function processes an array of filters and returns a new array + * containing only the valid filters. A filter is considered valid if: + * - It has an 'isEmpty' or 'isNotEmpty' operator, or + * - Its value is not empty (for array values, at least one element must be present; + * for other types, the value must not be an empty string, null, or undefined) + * + * @param filters - An array of Filter objects to be validated. + * @returns A new array containing only the valid filters. + */ +export function getValidFilters( + filters: Filter[] +): Filter[] { + return filters?.filter( + (filter) => + filter.operator === "isEmpty" || + filter.operator === "isNotEmpty" || + (Array.isArray(filter.value) + ? filter.value.length > 0 + : filter.value !== "" && + filter.value !== null && + filter.value !== undefined) + ) +} + +interface NumericFilterValue { + operator: string + inputValue?: number +} + + +export const numericFilter: FilterFn = ( + row: Row, + columnId: string, + filterValue: NumericFilterValue +) => { + const rowValue = row.getValue(columnId) + + // handle "isEmpty" / "isNotEmpty" + if (filterValue.operator === "isEmpty") { + return rowValue == null || rowValue === "" + } else if (filterValue.operator === "isNotEmpty") { + return !(rowValue == null || rowValue === "") + } + + // parse rowValue → numeric + const numericRowVal = + typeof rowValue === "number" ? rowValue : parseFloat(String(rowValue)) + + if (isNaN(numericRowVal)) { + // rowValue not a number + return false + } + + // parse filterValue.inputValue + const filterNum = filterValue.inputValue + if (filterNum == null || isNaN(filterNum)) { + // if user didn’t actually type a number, match everything or nothing (your choice) + return true + } + + // compare based on operator + switch (filterValue.operator) { + case "eq": + return numericRowVal === filterNum + case "ne": + return numericRowVal !== filterNum + case "lt": + return numericRowVal < filterNum + case "lte": + return numericRowVal <= filterNum + case "gt": + return numericRowVal > filterNum + case "gte": + return numericRowVal >= filterNum + default: + return true + } +} \ No newline at end of file diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts new file mode 100644 index 00000000..87977a0b --- /dev/null +++ b/lib/docuSign/docuSignFns.ts @@ -0,0 +1,383 @@ +"use server"; + +import docusign from "docusign-esign"; +import fs from "fs"; +import path from "path"; +import jwtConfig from "./jwtConfig/jwtConfig.json"; +import dayjs from "dayjs"; +import { ContractInfo, ContractorInfo } from "./types"; + +const SCOPES = ["signature", "impersonation"]; + +//DocuSign 인증 정보 +async function authenticate(): Promise< + | undefined + | { + accessToken: string; + apiAccountId: string; + basePath: string; + } +> { + const jwtLifeSec = 10 * 60; + const dsApi = new docusign.ApiClient(); + dsApi.setOAuthBasePath(jwtConfig.dsOauthServer.replace("https://", "")); + const privateKeyPath = path.resolve( + process.cwd(), + jwtConfig.privateKeyLocation + ); + + let rsaKey: Buffer = fs.readFileSync(privateKeyPath); + + try { + const results = await dsApi.requestJWTUserToken( + jwtConfig.dsJWTClientId, + jwtConfig.impersonatedUserGuid, + SCOPES, + rsaKey, + jwtLifeSec + ); + const accessToken = results.body.access_token; + + const userInfoResults = await dsApi.getUserInfo(accessToken); + let userInfo = userInfoResults.accounts.find( + (account: Partial<{ isDefault: string }>) => account.isDefault === "true" + ); + + return { + accessToken: results.body.access_token, + apiAccountId: userInfo.accountId, + basePath: `${userInfo.baseUri}/restapi`, + }; + } catch (e) { + console.error("❌ 인증 실패:", e); + } +} + +async function getSignerId( + basePath: string, + accountId: string, + accessToken: string, + envelopeId: string, + roleName: string +): Promise { + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const recipients = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers = recipients?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.roleName === roleName); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return null; + } + + return signer.recipientId as string; + } catch (error) { + console.error("❌ 서명자 ID 조회 실패:", error); + return null; + } +} + +//계약서 서명 요청 +export async function requestContractSign( + contractTemplateId: string, + contractInfo: ContractInfo[], + subcontractorinfo: ContractorInfo, + contractorInfo: ContractorInfo, + ccInfo: ContractorInfo[], + brandId: string | undefined = undefined +): Promise< + Partial<{ + result: boolean; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + if (accountInfo) { + const { accessToken, basePath, apiAccountId } = accountInfo; + const { + email: subEmail, + name: subConName, + roleName: subRoleName, + } = subcontractorinfo; + + const { + email: conEmail, + name: conName, + roleName: roleName, + } = contractorInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const signer1: docusign.TemplateRole = { + email: subEmail, + name: subConName, + roleName: subRoleName, + }; + + const signer1Tabs: docusign.Tabs = { + textTabs: [ + ...contractInfo.map((c): docusign.Text => { + const textField: docusign.Text = { + tabLabel: c.tabLabel, + value: c.value, + locked: "true", + }; + return textField; + }), + ], + }; + + const signer2: docusign.TemplateRole = { + email: conEmail, + name: conName, + roleName: roleName, + }; + + const signer2Tabs: docusign.Tabs = { + dateSignedTabs: [ + { + tabLabel: "contract_complete_date", + }, + ], + }; + + signer1.tabs = signer1Tabs; + signer2.tabs = signer2Tabs; + + const envelopeDefinition: docusign.EnvelopeDefinition = { + templateId: contractTemplateId, + templateRoles: [signer1, signer2, ...ccInfo], // 두 명의 서명자 추가 + status: "sent", // 즉시 발송 + }; + + if (brandId) { + envelopeDefinition.brandId = brandId; + } + + try { + let envelopeSummary = await envelopesApi.createEnvelope(apiAccountId, { + envelopeDefinition, + }); + + // console.log("✅ 서명 요청 완료, Envelope ID:", envelopeSummary); + return { + result: true, + envelopeId: envelopeSummary.envelopeId, + }; + } catch (error) { + console.dir(error); + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//서명된 계약서 다운로드 +export async function downloadContractFile(envelopeId: string): Promise< + Partial<{ + result: boolean; + fileName: string; + buffer: Buffer; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + + if (accountInfo) { + const { accessToken, apiAccountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + //Document ID 등 파일 정보를 호출 + const response = await envelopesApi.listDocuments( + apiAccountId, + envelopeId, + null + ); + + const { envelopeDocuments } = response || { envelopeDocuments: [] }; + + if (Array.isArray(envelopeDocuments) && envelopeDocuments.length > 0) { + const { documentId, name } = envelopeDocuments[0] as { + documentId: string; + name: string; + }; + + //Document Buffer 호출 + const downloadFile = await envelopesApi.getDocument( + apiAccountId, + envelopeId, + documentId, + {} + ); + + if (documentId && documentId !== "certificate") { + const bufferData: Buffer = downloadFile as unknown as Buffer; + return { + result: true, + fileName: name, + buffer: bufferData, + envelopeId, + }; + } + } + + return { + result: false, + }; + } catch (error) { + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//최종 서명 날짜 찾기 +export async function findContractCompleteTime( + envelopeId: string, + lastSignerRoleName: string +): Promise<{ + completedDateTime: string; + year: string; + month: string; + day: string; + time: string; +} | null> { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return null; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const envelope = await envelopesApi.getEnvelope(accountId, envelopeId); + if (!envelope.completedDateTime) { + console.error("❌ 서명 완료 날짜가 없습니다."); + return null; + } + + // 🔹 `SIGNER_ID` 가져오기 + const signerId = await getSignerId( + basePath, + accountId, + accessToken, + envelopeId, + lastSignerRoleName + ); + if (!signerId) { + console.error("❌ 서명자 ID를 찾을 수 없습니다."); + return null; + } + + const completedDate = dayjs(envelope.completedDateTime); + const year = completedDate.format("YYYY").toString(); + const month = completedDate.format("MM").toString(); + const day = completedDate.format("DD").toString(); + const time = completedDate.format("HH:mm").toString(); + + return { + completedDateTime: envelope.completedDateTime, + year, + month, + day, + time, + }; + } catch (error) { + console.error("❌ 서명 완료 후 날짜 추가 실패:", error); + return null; + } +} + +export async function getRecipients( + envelopeId: string, + recipientId: string +): Promise<{ result: boolean; message?: string }> { + try { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return { + result: false, + message: "인증 실패: API 요청을 중단합니다.", + }; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const response = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers: { [key: string]: any }[] = response?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.recipientId === recipientId); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return { + result: false, + message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.", + }; + } + + const { autoRespondedReason, status } = signer; + + if (autoRespondedReason || status === "status") { + return { + result: false, + message: autoRespondedReason, + }; + } + + return { + result: true, + }; + } catch (error) { + console.error("Error retrieving recipients:", error); + return { result: false, message: (error as Error).message }; + } +} diff --git a/lib/docuSign/jwtConfig/README.md b/lib/docuSign/jwtConfig/README.md new file mode 100644 index 00000000..7c997d07 --- /dev/null +++ b/lib/docuSign/jwtConfig/README.md @@ -0,0 +1,54 @@ +# DocuSign + +## DocuSign Contract Template + +### DocuSign Delveloper Account + +1. ID: kiman.kim@dtsolution.co.kr +2. PW: rlaks!153 + +### jwtConfig.json + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Apps and Keys 이동 + +```jwtConfig.json +{ + //Add App and Intergraion Key 시 private.key 파일 생성 (처음 key를 만들때만 저장 가능함.) + "privateKeyLocation": private.key 파일 경로, + "dsJWTClientId": Apps and Intergration Keys 내 Intergration Kzey, + "impersonatedUserGuid": My Account Information 내 User ID, + //개발환경: https://account-d.docusign.com + //운영환경: https://account.docusign.com + "dsOauthServer": "https://account-d.docusign.com" +} +``` + +### DocuSign Web Hook + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Connect 이동 +4. Add Configuration > Custom +5. Web Hook Url 입력 +6. Trigger Events + 6.1. Envelope Signed/Completed - Check + 6.2. Envelope Declined - Check + 6.3. Recipient Sent - Check + 6.4. Recipient Delivered - Check + 6.5. Recipient Signed/Completed - Check + 6.6. Recipient Declined - Check + +### DocuSign Mail Sender Info Change + +1. DocuSign Developer 로그인 +2. 우측 상단 유저 아이콘 클릭 후 Manage Profile Menu로 이동 +3. My Profile에서 Name 변경 + +### DocuSign Mail Templete Change + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 ACCOUNT > Brands 이동 +4. 사용하고자 하는 Brand 제작 후 BrandId 사용 diff --git a/lib/docuSign/jwtConfig/jwtConfig.json b/lib/docuSign/jwtConfig/jwtConfig.json new file mode 100644 index 00000000..756ca9dd --- /dev/null +++ b/lib/docuSign/jwtConfig/jwtConfig.json @@ -0,0 +1,6 @@ +{ + "dsJWTClientId": "4ecf089f-9134-4c6c-9657-d8f8c41b5965", + "impersonatedUserGuid": "de8ef3a2-9498-4855-a571-249a774a3905", + "privateKeyLocation": "./lib/docuSign/jwtConfig/private.key", + "dsOauthServer": "https://account-d.docusign.com" +} diff --git a/lib/docuSign/jwtConfig/private.key b/lib/docuSign/jwtConfig/private.key new file mode 100644 index 00000000..73c4291a --- /dev/null +++ b/lib/docuSign/jwtConfig/private.key @@ -0,0 +1,29 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnnjspqTabuuPLPi9Iga8U/chJRNmyr1PTbJC/Il0jse4ps/C +KGQdmVOsDzPW//dopMLVc5OmJ7I3y7lw2+TuJ0G7Ip7s6epV2dzqH9aA/yvHDwvj +2W9ZRH8pNx5AjNDCscwBF3NCK8CoGqK3+ukvuErVK8XQHnzOtAF2uyd2JLodT0fE +I+uyvIL1E5pzU5zHzxHoWCsrjKAVaHhWUiTP0migFYrMBMVWC30slvhrNg1qc4uT +Of3rkOGAUK+MFqCbaUm4qKBest9hDgSSw1h8Wv3cKD90KlRgZRSLSRxFwxzhj0ft +1ip+JIc8dLcax1+xhX0dKBW2GARchojxEAzhDQIDAQABAoIBABvVuyF5JsnhU7xv +M09Q9g7cg0SfIAi/0DhiNYxke2Xh1D/ukZilHyLRlND1xs+ebhG0jCf5GO/ziIPe +3mEtWJxqGfvWhOAAUlSKTlBJzc4kKxpsOPj16yzSFhPxmx5ww6XVoqJzEv4a4JwP +FTg78a8R69f8rpXQT8FD2Y49e+2uwVZVJfCjyaLcS2jh0wfaf7YiztSfyeAZNU2z +YIL05wDm6Kw8fsdgZ5tF+tEEx0xBelNh+g4fNVVYdQmUhTM0GHePH5KvLc7LQyxD +z/8ymU5fxikJGFmSS4ncI8ZpmCjV36tkUfZ03n5fW+76Q+gncc+ZKtXRZLgqBdsK +q9ZDTuECgYEAzXMpmOnZh6Mzw6js5WZ2jSw1vuHjEDBOxpKon9UXZD5wZh9bcuxr +ARQy+9/UETppumIW8L+zpmrpZISyriywEkleIjQhDqA9HJGR1lSukhMTyt4bj6ER +f3uyJUzFun5c/QTJEBEJneTFY/Zc4pB+KIdTf3EosVGbtBfkfUXvyyECgYEAxXbA +lg6gmo7ZpGZuPdhMrGiSI8rmGsvIo8Bw7jqdb6E/ksl5nBIxsLcM2lJw8Qe/fvei +g+4Zmc5NOzyOKO1L84ekOC6jfvnGR2jzS2hF/qcNLUEEOKEyzBeniWrAqt80fgeK +cH3zSAXCyLaGJPfdPPqEDYtVBN+zTwNJvHDK5G0CgYEAq1Lcnlpr/vL2iLQGkKno +NINocjw2OFrAZlEIcvik4AA9hLuja+uAs86fUXDujEtUvYtsq+iArEc9R4hs5Ff5 +n9Y0vHsSEftH2tn9bmkBhmiIOcUL4LMlP1TsUrR5srILYycpb891YIjUni5keL6b +pbprw7uefneaSw0dieXXOGECgYBrnmsb3WD+m3hWt1TB9A7lsCBlzYFXfVUemhVy +YRPI8TL6xz+2JdxbGYixvFi9pKFji4dRLAVb5CoHbNt1xs6sLXL9A74rx+mepb5j +jLMJNPZjgZnRW1maDhJLPJlBB2FOhsGWya47xJgCWCgIIea8AzTRROzTOTA6keov +/7E0iQKBgFUWjpHIC0wkBFQFAV1uji3P0Bp6/hCOq9hZNxiaS41AlrhrPDRcIqss +rMrW0Wf0OGDv0+aQXdMkk+nKBjQO3uS6EIj2oDUY/hTFXAKqvDPbHEx3rbtR7NdJ +Sx9/raUX3YoYSNbPwwKcIWiHVnqY/hI8zIb+RFZgwt+mEoLS9/a2 +-----END RSA PRIVATE KEY----- + + diff --git a/lib/docuSign/types.ts b/lib/docuSign/types.ts new file mode 100644 index 00000000..450199ce --- /dev/null +++ b/lib/docuSign/types.ts @@ -0,0 +1,37 @@ +export interface ContractInfo { + tabLabel: string; + value: string; +} + +export interface ContractorInfo { + email: string; + name: string; + roleName: string; +} + +export type poTabLabes = + | "po_no" + | "vendor_name" + | "po_date" + | "project_name" + | "vendor_location" + | "shi_email" + | "vendor_email" + | "po_desc" + | "qty" + | "unit_price" + | "total" + | "grand_total_amount" + | "tax_rate" + | "tax_total" + | "payment_amount" + | "remark"; + +type ContentMap = { + [K in T]: { + tabLabel: K; + value: string; + }; +}; + +export type POContent = ContentMap[poTabLabes][]; diff --git a/lib/downloadFile.ts b/lib/downloadFile.ts new file mode 100644 index 00000000..e2777976 --- /dev/null +++ b/lib/downloadFile.ts @@ -0,0 +1,81 @@ +'use server' + +import fs from 'fs/promises' +import path from 'path' +import { NextResponse } from 'next/server' + +/** + * 첨부 파일 다운로드를 위한 서버 액션 + * + * @param filePath 파일의 상대 경로 + * @returns 파일 내용(Base64 인코딩) 및 메타데이터를 포함한 객체 + */ +export async function downloadFileAction(filePath: string) { + try { + // 보안: 파일 경로가 uploads 디렉토리 내에 있는지 확인 + if (!filePath.startsWith('/uploads/') && !filePath.startsWith('uploads/')) { + return { + ok: false, + error: 'Invalid file path. Only files in the uploads directory can be downloaded.' + }; + } + + // 실제 서버 파일 시스템에서의 전체 경로 계산 + // 참고: process.cwd()는 현재 실행 중인 프로세스의 작업 디렉토리를 반환합니다. + // 환경에 따라 public 폴더나 다른 위치를 기준으로 할 수도 있습니다. + const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath; + const fullPath = path.join(process.cwd(), 'public', normalizedPath); + + // 파일 존재 여부 확인 + try { + await fs.access(fullPath); + } catch { + return { ok: false, error: 'File not found' }; + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath); + + // 파일 통계 정보 가져오기 + const stats = await fs.stat(fullPath); + + // MIME 타입 추측 + const extension = path.extname(fullPath).toLowerCase(); + let mimeType = 'application/octet-stream'; // 기본값 + + // 일반적인 파일 타입에 대한 MIME 타입 매핑 + const mimeTypes = { + '.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', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.txt': 'text/plain', + }; + + if (extension in mimeTypes) { + mimeType = mimeTypes[extension]; + } + + // Base64로 인코딩하여 반환 + return { + ok: true, + data: { + content: fileBuffer.toString('base64'), + fileName: path.basename(fullPath), + size: stats.size, + mimeType, + }, + }; + } catch (error) { + console.error('Download error:', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'An unknown error occurred' + }; + } +} \ No newline at end of file diff --git a/lib/equip-class/repository.ts b/lib/equip-class/repository.ts new file mode 100644 index 00000000..ddf98dd2 --- /dev/null +++ b/lib/equip-class/repository.ts @@ -0,0 +1,45 @@ +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { tagClasses } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectTagClassLists( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tagClasses) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } + /** 총 개수 count */ + export async function countTagClassLists( + tx: PgTransaction, + where?: any + ) { + const res = await tx.select({ count: count() }).from(tagClasses).where(where); + return res[0]?.count ?? 0; + } \ No newline at end of file diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts new file mode 100644 index 00000000..c35f4fbe --- /dev/null +++ b/lib/equip-class/service.ts @@ -0,0 +1,85 @@ +"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 { filterColumns } from "@/lib/filter-columns"; +import { tagClasses } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { GetTagClassesSchema } from "./validation"; +import { countTagClassLists, selectTagClassLists } from "./repository"; + +export async function getTagClassists(input: GetTagClassesSchema) { + + 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: tagClasses, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(tagClasses.code, s), ilike(tagClasses.label, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tagClasses[item.id]) : asc(tagClasses[item.id]) + ) + : [asc(tagClasses.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTagClassLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countTagClassLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 + } + )(); + } \ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table-columns.tsx b/lib/equip-class/table/equipClass-table-columns.tsx new file mode 100644 index 00000000..1255abf3 --- /dev/null +++ b/lib/equip-class/table/equipClass-table-columns.tsx @@ -0,0 +1,99 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +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 { TagClasses } from "@/db/schema/vendorData" +import { equipclassColumnsConfig } from "@/config/equipClassColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + equipclassColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 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, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + ] +} \ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table-toolbar-actions.tsx b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx new file mode 100644 index 00000000..5e03d800 --- /dev/null +++ b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { TagClasses } from "@/db/schema/vendorData" + + + +interface ItemsTableToolbarActionsProps { + table: Table +} + +export function EquipClassTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + + + return ( +
+ {/** 4) Export 버튼 */} + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table.tsx b/lib/equip-class/table/equipClass-table.tsx new file mode 100644 index 00000000..56fd42aa --- /dev/null +++ b/lib/equip-class/table/equipClass-table.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "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 { useFeatureFlags } from "./feature-flags-provider" + +import { TagClasses } from "@/db/schema/vendorData" +import { getTagClassists } from "../service" +import { EquipClassTableToolbarActions } from "./equipClass-table-toolbar-actions" +import { getColumns } from "./equipClass-table-columns" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function EquipClassTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + +console.log(data) + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "code", + label: "Code", + type: "text", + // group: "Basic Info", + }, + { + id: "label", + label: "Label", + type: "text", + // group: "Basic Info", + }, + + + { + id: "createdAt", + label: "Created At", + type: "date", + // group: "Metadata",a + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + // group: "Metadata", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + + ) +} diff --git a/lib/equip-class/table/feature-flags-provider.tsx b/lib/equip-class/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/equip-class/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/equip-class/validation.ts b/lib/equip-class/validation.ts new file mode 100644 index 00000000..48698ac4 --- /dev/null +++ b/lib/equip-class/validation.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TagClasses } from "@/db/schema/vendorData"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + code: parseAsString.withDefault(""), + label: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetTagClassesSchema = Awaited> diff --git a/lib/export.ts b/lib/export.ts new file mode 100644 index 00000000..d910ef6a --- /dev/null +++ b/lib/export.ts @@ -0,0 +1,198 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" + +/** + * `exportTableToExcel`: + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - onlySelected: 선택된 행만 내보낼지 여부 + * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportTableToExcel( + table: Table, + { + filename = "table", + onlySelected = false, + excludeColumns = [], + useGroupHeader = true, + }: { + filename?: string + onlySelected?: boolean + excludeColumns?: string[] + useGroupHeader?: boolean + } = {} +): Promise { + // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // 2) excludeColumns 목록에 들어있는 col.id 제거 + const columns = allColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + const maybeGroup = (col.columnDef.meta as any)?.group + row1.push(maybeGroup ?? "") + + // excelHeader + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + if (typeof maybeExcelHeader === "string") { + row2.push(maybeExcelHeader) + } else { + row2.push(col.id) + } + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => { + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + return typeof maybeExcelHeader === "string" ? maybeExcelHeader : col.id + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // (추가) 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + // row2 (인덱스 2) = 실제 컬럼 헤더 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + c - 1 + ) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + columns.length + ) + } + } + + // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} \ No newline at end of file diff --git a/lib/export_all.ts b/lib/export_all.ts new file mode 100644 index 00000000..6f925fbc --- /dev/null +++ b/lib/export_all.ts @@ -0,0 +1,251 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" + +/** + * `exportTableToExcel`: + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - onlySelected: 선택된 행만 내보낼지 여부 + * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + * - allPages: true일 경우, 페이징 상관없이 모든 행을 내보냄 + * + * 추가: + * - i18n: (key: string) => string | undefined + * => excelHeader나 group 값이 'myKey'처럼 i18n 키라면 이 함수를 통해 번역 문자열 반환 + * - customHeaders: { [colId: string]: string } + * => 특정 col.id에 대해 강제로 헤더를 지정하고 싶을 때 사용 + */ +export async function exportTableToExcel( + table: Table, + { + filename = "table", + onlySelected = false, + excludeColumns = [], + useGroupHeader = true, + allPages = false, + /** 아래 2개가 새로 추가된 옵션 */ + i18n, + customHeaders = {}, + }: { + filename?: string + onlySelected?: boolean + excludeColumns?: string[] + useGroupHeader?: boolean + allPages?: boolean + /** excelHeader나 group 값이 i18n 키일 경우, 해당 함수를 통해 번역 */ + i18n?: (key: string) => string + /** 특정 col.id에 대한 강제 헤더 지정 */ + customHeaders?: Record + } = {} +): Promise { + // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // 2) excludeColumns 목록에 들어있는 col.id 제거 + const columns = allColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + // 실제로 기록할 sheetData(배열 형식) + let sheetData: any[][] + + // ────────────── 2줄 헤더 (group + excelHeader) vs 1줄 헤더 ────────────── + if (useGroupHeader) { + // 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + const meta = col.columnDef.meta as any + // 1) group (그룹헤더) + const groupKey = meta?.group + let groupLabel = groupKey ?? "" + if (groupLabel && i18n) { + // groupKey가 i18n 키라면 번역 적용 + const maybeTranslated = i18n(groupLabel) + if (maybeTranslated) { + groupLabel = maybeTranslated + } + } + row1.push(groupLabel) + + // 2) excelHeader (실제 컬럼 헤더) + // (a) customHeaders[col.id]가 우선 + if (customHeaders[col.id]) { + row2.push(customHeaders[col.id]) + } else { + // (b) meta?.excelHeader가 있으면 그것을 사용 + const maybeExcelHeader = meta?.excelHeader + if (typeof maybeExcelHeader === "string") { + // i18n 함수가 있다면 i18n 키로 가정하고 번역 시도 + if (i18n) { + const maybeTranslated = i18n(maybeExcelHeader) + row2.push(maybeTranslated || maybeExcelHeader) + } else { + row2.push(maybeExcelHeader) + } + } else { + // 모두 없으면 col.id 사용 + row2.push(col.id) + } + } + }) + + // ───────────────────────────────────────────────── + // 필요한 데이터 행 추출 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : allPages + ? table.getPrePaginationRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => { + const meta = col.columnDef.meta as any + + // 1) customHeaders[col.id]가 우선 + if (customHeaders[col.id]) { + return customHeaders[col.id] + } + + // 2) meta?.excelHeader가 문자열이면 + if (typeof meta?.excelHeader === "string") { + if (i18n) { + const maybeTranslated = i18n(meta.excelHeader) + return maybeTranslated || meta.excelHeader + } else { + return meta.excelHeader + } + } + + // 3) 모든 것이 없으면 col.id + return col.id + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : allPages + ? table.getPrePaginationRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // (추가) 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + // row2 (인덱스 2) = 실제 컬럼 헤더 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} \ No newline at end of file diff --git a/lib/filter-columns.ts b/lib/filter-columns.ts new file mode 100644 index 00000000..4b995925 --- /dev/null +++ b/lib/filter-columns.ts @@ -0,0 +1,193 @@ +import { isEmpty, isNotEmpty } from "@/db/utils" +import type { Filter, JoinOperator } from "@/types/table" +import { addDays, endOfDay, startOfDay } from "date-fns" +import { + and, + eq, + gt, + gte, + ilike, + inArray, + lt, + lte, + ne, + notIlike, + notInArray, + or, + type AnyColumn, + type SQL, + type Table, +} from "drizzle-orm" +import type { PgTable, PgView } from "drizzle-orm/pg-core" + +type TableOrView = PgTable | PgView + +/** + * Construct SQL conditions based on the provided filters for a specific table. + * + * This function takes a table and an array of filters, and returns a SQL + * expression that represents the logical combination of these conditions. The conditions + * are combined using the specified join operator (either 'AND' or 'OR'), which is determined + * by the first filter's joinOperator property. + * + * Each filter can specify various operators (e.g., equality, inequality, + * comparison for numbers and dates, etc.) and the function will generate the appropriate + * SQL expressions based on the filter's type and value. + * + * @param table - The table to apply the filters on. + * @param filters - An array of filters to be applied to the table. + * @param joinOperator - The join operator to use for combining the filters. + * @returns A SQL expression representing the combined filters, or undefined if no valid + * filters are found. + */ + +export function filterColumns({ + table, + filters, + joinOperator, +}: { + table: T + filters: Filter[] + joinOperator: JoinOperator +}): SQL | undefined { + + const joinFn = joinOperator === "and" ? and : or + + const conditions = filters.map((filter) => { + const column = getColumn(table, filter.id) + + switch (filter.operator) { + case "eq": + if (Array.isArray(filter.value)) { + return inArray(column, filter.value) + } else if ( + column.dataType === "boolean" && + typeof filter.value === "string" + ) { + return eq(column, filter.value === "true") + } else if (filter.type === "date") { + const date = new Date(filter.value) + const start = startOfDay(date) + const end = endOfDay(date) + return and(gte(column, start), lte(column, end)) + } else { + return eq(column, filter.value) + } + case "ne": + if (Array.isArray(filter.value)) { + return notInArray(column, filter.value) + } else if (column.dataType === "boolean") { + return ne(column, filter.value === "true") + } else if (filter.type === "date") { + const date = new Date(filter.value) + const start = startOfDay(date) + const end = endOfDay(date) + return or(lt(column, start), gt(column, end)) + } else { + return ne(column, filter.value) + } + case "iLike": + return filter.type === "text" && typeof filter.value === "string" + ? ilike(column, `%${filter.value}%`) + : undefined + case "notILike": + return filter.type === "text" && typeof filter.value === "string" + ? notIlike(column, `%${filter.value}%`) + : undefined + case "lt": + return filter.type === "number" + ? lt(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? lt(column, endOfDay(new Date(filter.value))) + : undefined + case "lte": + return filter.type === "number" + ? lte(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? lte(column, endOfDay(new Date(filter.value))) + : undefined + case "gt": + return filter.type === "number" + ? gt(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? gt(column, startOfDay(new Date(filter.value))) + : undefined + case "gte": + return filter.type === "number" + ? gte(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? gte(column, startOfDay(new Date(filter.value))) + : undefined + case "isBetween": + return filter.type === "date" && + Array.isArray(filter.value) && + filter.value.length === 2 + ? and( + filter.value[0] + ? gte(column, startOfDay(new Date(filter.value[0]))) + : undefined, + filter.value[1] + ? lte(column, endOfDay(new Date(filter.value[1]))) + : undefined + ) + : undefined + case "isRelativeToToday": + if (filter.type === "date" && typeof filter.value === "string") { + const today = new Date() + const [amount, unit] = filter.value.split(" ") ?? [] + let startDate: Date + let endDate: Date + + if (!amount || !unit) return undefined + + switch (unit) { + case "days": + startDate = startOfDay(addDays(today, parseInt(amount))) + endDate = endOfDay(startDate) + break + case "weeks": + startDate = startOfDay(addDays(today, parseInt(amount) * 7)) + endDate = endOfDay(addDays(startDate, 6)) + break + case "months": + startDate = startOfDay(addDays(today, parseInt(amount) * 30)) + endDate = endOfDay(addDays(startDate, 29)) + break + default: + return undefined + } + + return and(gte(column, startDate), lte(column, endDate)) + } + return undefined + case "isEmpty": + return isEmpty(column) + case "isNotEmpty": + return isNotEmpty(column) + + default: + throw new Error(`Unsupported operator: ${filter.operator}`) + } + }) + + const validConditions = conditions.filter( + (condition) => condition !== undefined + ) + + + return validConditions.length > 0 ? joinFn(...validConditions) : undefined +} + +/** + * Get table column. + * @param table The table to get the column from. + * @param columnKey The key of the column to retrieve from the table. + * @returns The column corresponding to the provided key. + */ + +export function getColumn( + table: T, + columnKey: keyof T +): AnyColumn { + return table[columnKey] as AnyColumn +} \ No newline at end of file diff --git a/lib/fonts.ts b/lib/fonts.ts new file mode 100644 index 00000000..c5e8958d --- /dev/null +++ b/lib/fonts.ts @@ -0,0 +1,5 @@ +import { GeistMono } from "geist/font/mono" +import { GeistSans } from "geist/font/sans" + +export const fontSans = GeistSans +export const fontMono = GeistMono diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts new file mode 100644 index 00000000..ced320db --- /dev/null +++ b/lib/form-list/repository.ts @@ -0,0 +1,46 @@ +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectFormLists( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tagTypeClassFormMappings) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } + /** 총 개수 count */ + export async function countFormLists( + tx: PgTransaction, + where?: any + ) { + const res = await tx.select({ count: count() }).from(tagTypeClassFormMappings).where(where); + return res[0]?.count ?? 0; + } + \ No newline at end of file diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts new file mode 100644 index 00000000..64156cf4 --- /dev/null +++ b/lib/form-list/service.ts @@ -0,0 +1,84 @@ +"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 { GetFormListsSchema } from "./validation"; +import { filterColumns } from "@/lib/filter-columns"; +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { countFormLists, selectFormLists } from "./repository"; + +export async function getFormLists(input: GetFormListsSchema) { + + 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: tagTypeClassFormMappings, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(tagTypeClassFormMappings.formCode, s), ilike(tagTypeClassFormMappings.formName, s) + , ilike(tagTypeClassFormMappings.tagTypeLabel, s) , ilike(tagTypeClassFormMappings.classLabel, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tagTypeClassFormMappings[item.id]) : asc(tagTypeClassFormMappings[item.id]) + ) + : [asc(tagTypeClassFormMappings.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectFormLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countFormLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["form-lists"], // revalidateTag("items") 호출 시 무효화 + } + )(); + } \ No newline at end of file diff --git a/lib/form-list/table/feature-flags-provider.tsx b/lib/form-list/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/form-list/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/form-list/table/formLists-table-columns.tsx b/lib/form-list/table/formLists-table-columns.tsx new file mode 100644 index 00000000..f638c4df --- /dev/null +++ b/lib/form-list/table/formLists-table-columns.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +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 { formListsColumnsConfig } from "@/config/formListsColumnsConfig" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + + View Meta Info + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + formListsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 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, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionsColumn, + ] +} \ No newline at end of file diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx new file mode 100644 index 00000000..346a3980 --- /dev/null +++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" + + + +interface ItemsTableToolbarActionsProps { + table: Table +} + +export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + + + return ( +
+ {/** 4) Export 버튼 */} + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx new file mode 100644 index 00000000..be252655 --- /dev/null +++ b/lib/form-list/table/formLists-table.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "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 { useFeatureFlags } from "./feature-flags-provider" + +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" +import { getFormLists } from "../service" +import { getColumns } from "./formLists-table-columns" +import { FormListsTableToolbarActions } from "./formLists-table-toolbar-actions" +import { ViewMetas } from "./meta-sheet" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function FormListsTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField[] = [ + + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "formCode", + label: "Form Code", + type: "text", + + }, + { + id: "formName", + label: "Form Name", + type: "text", + + }, + { + id: "tagTypeLabel", + label: "Tag Type", + type: "text", + + }, + { + id: "classLabel", + label: "Class", + type: "text", + + }, + + { + id: "createdAt", + label: "Created At", + type: "date", + + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + setRowAction(null)} + form={rowAction?.row.original ?? null} + /> + + + ) +} diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx new file mode 100644 index 00000000..155e4f5a --- /dev/null +++ b/lib/form-list/table/meta-sheet.tsx @@ -0,0 +1,245 @@ +"use client" + +import * as React from "react" +import { useMemo } from "react" +import { Badge } from "@/components/ui/badge" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from "@/components/ui/sheet" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@/components/ui/tabs" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card" +import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type +import { fetchFormMetadata, FormColumn } from "@/lib/forms/services" + + +interface ViewMetasProps { + open: boolean + onOpenChange: (open: boolean) => void + form: TagTypeClassFormMappings | null +} + +export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { + // metadata & loading + const [metadata, setMetadata] = React.useState<{ + formName: string + formCode: string + columns: FormColumn[] + } | null>(null) + const [loading, setLoading] = React.useState(false) + + // Group columns by type for better organization + const groupedColumns = useMemo(() => { + if (!metadata?.columns) return {} + + return metadata.columns.reduce((acc, column) => { + const type = column.type + if (!acc[type]) { + acc[type] = [] + } + acc[type].push(column) + return acc + }, {} as Record) + }, [metadata]) + + // Types for the tabs + const columnTypes = useMemo(() => { + return Object.keys(groupedColumns) + }, [groupedColumns]) + + // Fetch metadata when form changes and dialog is opened + React.useEffect(() => { + async function fetchMeta() { + if (!form || !open) return + + setLoading(true) + try { + // 서버 액션 호출 + const metaData = await fetchFormMetadata(form.formCode) + if (metaData) { + setMetadata(metaData) + } else { + setMetadata(null) + } + } catch (error) { + console.error("Error fetching form metadata:", error) + setMetadata(null) + } finally { + setLoading(false) + } + } + + fetchMeta() + }, [form, open]) + + if (!form) return null + + return ( + + + + + Form Metadata + + + {loading ? ( +
Loading metadata...
+ ) : metadata ? ( +
+
+ Form Code: + {metadata.formCode} +
+
+ Form Name: + {metadata.formName} +
+
+ ) : ( +
+ No metadata found for form code: {form.formCode} +
+ )} + +
+ + {loading ? ( +
+
+
+ ) : metadata ? ( + + + All ({metadata.columns.length}) + {columnTypes.map((type) => ( + + {type} ({groupedColumns[type].length}) + + ))} + + + + + + All Fields + All form fields and their properties + + + + + + Key + Label + Type + Options + + + + {metadata.columns.map((column) => ( + + {column.key} + {column.label} + + {column.type} + + + {column.options ? ( +
+ {column.options.map((option) => ( + + {option} + + ))} +
+ ) : ( + "-" + )} +
+
+ ))} +
+
+
+
+
+ + {columnTypes.map((type) => ( + + + + {type.charAt(0).toUpperCase() + type.slice(1)} Fields + Fields with type "{type}" + + + + + + Key + Label + {type === "select" && Options} + + + + {groupedColumns[type].map((column) => ( + + {column.key} + {column.label} + {type === "select" && ( + + {column.options ? ( +
+ {column.options.map((option) => ( + + {option} + + ))} +
+ ) : ( + "-" + )} +
+ )} +
+ ))} +
+
+
+
+
+ ))} +
+ ) : ( +
+
No metadata found
+

+ Could not find metadata for form code: {form.formCode} +

+
+ )} + +
+
+ ) +} \ No newline at end of file diff --git a/lib/form-list/validation.ts b/lib/form-list/validation.ts new file mode 100644 index 00000000..c8baf960 --- /dev/null +++ b/lib/form-list/validation.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + tagTypeLabel: parseAsString.withDefault(""), + classLabel: parseAsString.withDefault(""), + formCode: parseAsString.withDefault(""), + formName: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetFormListsSchema = Awaited> diff --git a/lib/forms/services.ts b/lib/forms/services.ts new file mode 100644 index 00000000..e5fc8666 --- /dev/null +++ b/lib/forms/services.ts @@ -0,0 +1,645 @@ +// lib/forms/services.ts +"use server" + +import db from "@/db/db"; +import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings } from "@/db/schema/vendorData" +import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm" +import { unstable_cache } from "next/cache" +import { revalidateTag } from "next/cache" +import { getErrorMessage } from "../handle-error"; +import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; + +export interface FormInfo { + id: number + formCode: string + formName: string + // tagType: string +} + +export async function getFormsByContractItemId(contractItemId: number | null) { + // 유효성 검사 + if (!contractItemId || contractItemId <= 0) { + console.warn(`Invalid contractItemId: ${contractItemId}`); + return { forms: [] }; + } + + // 고유 캐시 키 + const cacheKey = `forms-${contractItemId}`; + + try { + return unstable_cache( + async () => { + console.log(`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`); + + try { + // 데이터베이스에서 폼 조회 + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + console.log(`[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`); + + // 결과가 배열인지 확인 + if (!Array.isArray(formRecords)) { + getErrorMessage(`Unexpected result format for contractItemId ${contractItemId} ${formRecords}`); + return { forms: [] }; + } + + return { forms: formRecords }; + } catch (error) { + getErrorMessage(`Database error for contractItemId ${contractItemId}: ${error}`); + throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 + } + }, + [cacheKey], + { + // 캐시 시간 단축 + revalidate: 60, // 1분으로 줄임 + tags: [cacheKey] + } + )(); + } catch (error) { + getErrorMessage(`Cache operation failed for contractItemId ${contractItemId}: ${error}`); + + // 캐시 문제 시 직접 쿼리 시도 + try { + console.log(`[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`); + + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + return { forms: formRecords }; + } catch (dbError) { + getErrorMessage(`Fallback query failed for contractItemId ${contractItemId}:${dbError}`); + return { forms: [] }; + } + } +} + +/** + * 폼 캐시를 갱신하는 서버 액션 + */ +export async function revalidateForms(contractItemId: number) { + if (!contractItemId) return; + + const cacheKey = `forms-${contractItemId}`; + console.log(`[Forms Service] Invalidating cache for ${cacheKey}`); + + try { + revalidateTag(cacheKey); + console.log(`[Forms Service] Cache invalidated for ${cacheKey}`); + } catch (error) { + getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`); + } +} + +/** + * "가장 최신 1개 row"를 가져오고, + * data가 배열이면 그 배열을 반환, + * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. + */ +export async function getFormData(formCode: string, contractItemId: number) { + // 고유 캐시 키 (formCode + contractItemId) + const cacheKey = `form-data-${formCode}-${contractItemId}` + + try { + // 1) unstable_cache로 전체 로직을 감싼다 + const result = await unstable_cache( + async () => { + // --- 기존 로직 시작 --- + // (1) form_metas 조회 (가정상 1개만 존재) + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행 + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + // columns: DB에 저장된 JSON (DataTableColumnJSON[]) + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열 + let data: Array> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array.") + } + } + + return { columns, data } + // --- 기존 로직 끝 --- + }, + [cacheKey], // 캐시 키 의존성 + { + revalidate: 60, // 1분 캐시 + tags: [cacheKey], // 캐시 태그 + } + )() + + return result + } catch (cacheError) { + console.error(`[getFormData] Cache operation failed:`, cacheError) + + // --- fallback: 캐시 문제 시 직접 쿼리 시도 --- + try { + console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`) + + // (1) form_metas + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + + // (2) form_entries + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + let data: Array> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array (fallback).") + } + } + + return { columns, data } + } catch (dbError) { + console.error(`[getFormData] Fallback DB query failed:`, dbError) + return { columns: null, data: [] } + } + } +} + +// export async function syncMissingTags(contractItemId: number, formCode: string) { + + +// // (1) forms 테이블에서 (contractItemId, formCode) 찾기 +// const [formRow] = await db +// .select() +// .from(forms) +// .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))) +// .limit(1) + +// if (!formRow) { +// throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`) +// } + +// const { tagType, class: className } = formRow + +// // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기 +// const tagRows = await db +// .select() +// .from(tags) +// .where( +// and( +// eq(tags.contractItemId, contractItemId), +// eq(tags.tagType, tagType), +// eq(tags.class, className), +// ) +// ) + +// if (tagRows.length === 0) { +// console.log("No matching tags found.") +// return { createdCount: 0 } +// } + +// // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회 +// let [entry] = await db +// .select() +// .from(formEntries) +// .where( +// and( +// eq(formEntries.contractItemId, contractItemId), +// eq(formEntries.formCode, formCode) +// ) +// ) +// .limit(1) + +// // (4) 만약 없다면 새로 insert: data = [] +// if (!entry) { +// const [inserted] = await db.insert(formEntries).values({ +// contractItemId, +// formCode, +// data: [], // 초기 상태는 빈 배열 +// }).returning() +// entry = inserted +// } + +// // entry.data는 배열이라고 가정 +// // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅 +// const existingData = entry.data as Array<{ tagNumber: string }> +// let createdCount = 0 + +// // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push +// const updatedArray = [...existingData] +// for (const tagRow of tagRows) { +// const tagNo = tagRow.tagNo +// const found = updatedArray.some(item => item.tagNumber === tagNo) +// if (!found) { +// updatedArray.push({ tagNumber: tagNo }) +// createdCount++ +// } +// } + +// // (6) 변경이 있으면 UPDATE +// if (createdCount > 0) { +// await db +// .update(formEntries) +// .set({ data: updatedArray }) +// .where(eq(formEntries.id, entry.id)) +// } + + +// revalidateTag(`form-data-${formCode}-${contractItemId}`); + +// return { createdCount } +// } + +export async function syncMissingTags(contractItemId: number, formCode: string) { + // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). + const [formRow] = await db + .select() + .from(forms) + .where( + and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode)) + ) + .limit(1) + + if (!formRow) { + throw new Error( + `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` + ) + } + + // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode. + const formMappings = await db + .select() + .from(tagTypeClassFormMappings) + .where(eq(tagTypeClassFormMappings.formCode, formCode)) + + // If no mappings are found, there's nothing to sync. + if (formMappings.length === 0) { + console.log(`No mappings found for formCode=${formCode}`) + return { createdCount: 0, updatedCount: 0, deletedCount: 0 } + } + + // Build a dynamic OR clause to match (tagType, class) pairs from the mappings. + const orConditions = formMappings.map((m) => + and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel)) + ) + + // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. + const tagRows = await db + .select() + .from(tags) + .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))) + + // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). + let [entry] = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, contractItemId), + eq(formEntries.formCode, formCode) + ) + ) + .limit(1) + + if (!entry) { + const [inserted] = await db + .insert(formEntries) + .values({ + contractItemId, + formCode, + data: [], // Initialize with empty array + }) + .returning() + entry = inserted + } + + // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정 + const existingData = entry.data as Array<{ + tagNumber: string + tagDescription?: string + }> + + // Create a Set of valid tagNumbers from tagRows for efficient lookup + const validTagNumbers = new Set(tagRows.map(tag => tag.tagNo)) + + // Copy existing data to work with + let updatedData: Array<{ + tagNumber: string + tagDescription?: string + }> = [] + + let createdCount = 0 + let updatedCount = 0 + let deletedCount = 0 + + // First, filter out items that should be deleted (not in validTagNumbers) + for (const item of existingData) { + if (validTagNumbers.has(item.tagNumber)) { + updatedData.push(item) + } else { + deletedCount++ + } + } + + // (5) For each tagRow, if it's missing in updatedData, push it in. + // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음. + for (const tagRow of tagRows) { + const { tagNo, description } = tagRow + + // 5-1. 기존 데이터에서 tagNumber 매칭 + const existingIndex = updatedData.findIndex( + (item) => item.tagNumber === tagNo + ) + + // 5-2. 없다면 새로 추가 + if (existingIndex === -1) { + updatedData.push({ + tagNumber: tagNo, + tagDescription: description ?? "", + }) + createdCount++ + } else { + // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) + const existingItem = updatedData[existingIndex] + if (existingItem.tagDescription !== description) { + updatedData[existingIndex] = { + ...existingItem, + tagDescription: description ?? "", + } + updatedCount++ + } + } + } + + // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 + if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { + await db + .update(formEntries) + .set({ data: updatedData }) + .where(eq(formEntries.id, entry.id)) + } + + // 캐시 무효화 등 후처리 + revalidateTag(`form-data-${formCode}-${contractItemId}`) + + return { createdCount, updatedCount, deletedCount } +} + +/** + * updateFormDataInDB: + * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, + * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트 + * 업데이트 후, revalidateTag()로 캐시 무효화. + */ +type UpdateResponse = { + success: boolean + message: string + data?: any +} + +export async function updateFormDataInDB( + formCode: string, + contractItemId: number, + newData: Record +): Promise { + try { + // 1) tagNumber로 식별 + const tagNumber = newData.tagNumber + if (!tagNumber) { + return { + success: false, + message: "tagNumber는 필수 항목입니다." + } + } + + // 2) row 찾기 (단 하나) + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1) + + if (!entries || entries.length === 0) { + return { + success: false, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})` + } + } + + const entry = entries[0] + + // 3) data가 배열인지 확인 + if (!entry.data) { + return { + success: false, + message: "폼 데이터가 없습니다." + } + } + + const dataArray = entry.data as Array> + if (!Array.isArray(dataArray)) { + return { + success: false, + message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다." + } + } + + // 4) tagNumber = newData.tagNumber 항목 찾기 + const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber) + if (idx < 0) { + return { + success: false, + message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.` + } + } + + // 5) 병합 + const oldItem = dataArray[idx] + const updatedItem = { + ...oldItem, + ...newData, + tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지 + } + + const updatedArray = [...dataArray] + updatedArray[idx] = updatedItem + + // 6) DB UPDATE + try { + await db + .update(formEntries) + .set({ + data: updatedArray, + updatedAt: new Date() // 업데이트 시간도 갱신 + }) + .where(eq(formEntries.id, entry.id)) + } catch (dbError) { + console.error("Database update error:", dbError) + + if (dbError instanceof DrizzleError) { + return { + success: false, + message: `데이터베이스 업데이트 오류: ${dbError.message}` + } + } + + return { + success: false, + message: "데이터베이스 업데이트 중 오류가 발생했습니다." + } + } + + // 7) Cache 무효화 + try { + // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 + const cacheTag = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheTag) + } catch (cacheError) { + console.warn("Cache revalidation warning:", cacheError) + // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김 + } + + return { + success: true, + message: '데이터가 성공적으로 업데이트되었습니다.', + data: { + tagNumber, + updatedFields: Object.keys(newData).filter(key => key !== 'tagNumber') + } + } + } catch (error) { + // 예상치 못한 오류 처리 + console.error("Unexpected error in updateFormDataInDB:", error) + return { + success: false, + message: error instanceof Error + ? `예상치 못한 오류가 발생했습니다: ${error.message}` + : "알 수 없는 오류가 발생했습니다." + } + } +} + +// FormColumn Type (동일) +export interface FormColumn { + key: string + type: string + label: string + options?: string[] +} + +interface MetadataResult { + formName: string + formCode: string + columns: FormColumn[] +} + +/** + * 서버 액션: + * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서 + * { formName, formCode, columns } 형태로 반환. + * 없으면 null. + */ +export async function fetchFormMetadata(formCode: string): Promise { + try { + // 기존 방식: select().from().where() + const rows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .limit(1) + + // rows는 배열 + const metaData = rows[0] + if (!metaData) return null + + return { + formCode: metaData.formCode, + formName: metaData.formName, + columns: metaData.columns as FormColumn[] + } + } catch (err) { + console.error("Error in fetchFormMetadata:", err) + return null + } +} \ No newline at end of file diff --git a/lib/handle-error.ts b/lib/handle-error.ts new file mode 100644 index 00000000..1f608723 --- /dev/null +++ b/lib/handle-error.ts @@ -0,0 +1,22 @@ +import { toast } from "sonner" +import { z } from "zod" + +export function getErrorMessage(err: unknown) { + const unknownError = "Something went wrong, please try again later." + + if (err instanceof z.ZodError) { + const errors = err.issues.map((issue) => { + return issue.message + }) + return errors.join("\n") + } else if (err instanceof Error) { + return err.message + } else { + return unknownError + } +} + +export function showErrorToast(err: unknown) { + const errorMessage = getErrorMessage(err) + return toast.error(errorMessage) +} diff --git a/lib/id.ts b/lib/id.ts new file mode 100644 index 00000000..e6e44cbd --- /dev/null +++ b/lib/id.ts @@ -0,0 +1,43 @@ +import { customAlphabet } from "nanoid" + +const prefixes = { + task: "tsk", +} + +interface GenerateIdOptions { + /** + * The length of the generated ID. + * @default 12 + * @example 12 => "abc123def456" + * */ + length?: number + /** + * The separator to use between the prefix and the generated ID. + * @default "_" + * @example "_" => "str_abc123" + * */ + separator?: string +} + +/** + * Generates a unique ID with optional prefix and configuration. + * @param prefixOrOptions The prefix string or options object + * @param options The options for generating the ID + */ +export function generateId( + prefixOrOptions?: keyof typeof prefixes | GenerateIdOptions, + options: GenerateIdOptions = {} +) { + if (typeof prefixOrOptions === "object") { + options = prefixOrOptions + prefixOrOptions = undefined + } + + const { length = 12, separator = "_" } = options + const id = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + length + )() + + return prefixOrOptions ? `${prefixes[prefixOrOptions]}${separator}${id}` : id +} diff --git a/lib/items/repository.ts b/lib/items/repository.ts new file mode 100644 index 00000000..550e6b1d --- /dev/null +++ b/lib/items/repository.ts @@ -0,0 +1,125 @@ +// src/lib/items/repository.ts +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +export type NewItem = typeof items.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectItems( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(items) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countItems( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(items).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertItem( + tx: PgTransaction, + data: NewItem // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(items) + .values(data) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertItems( + tx: PgTransaction, + data: Item[] +) { + return tx.insert(items).values(data).onConflictDoNothing(); +} + + + +/** 단건 삭제 */ +export async function deleteItemById( + tx: PgTransaction, + itemId: number +) { + return tx.delete(items).where(eq(items.id, itemId)); +} + +/** 복수 삭제 */ +export async function deleteItemsByIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(items).where(inArray(items.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllItems( + tx: PgTransaction, +) { + return tx.delete(items); +} + +/** 단건 업데이트 */ +export async function updateItem( + tx: PgTransaction, + itemId: number, + data: Partial +) { + return tx + .update(items) + .set(data) + .where(eq(items.id, itemId)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 업데이트 */ +export async function updateItems( + tx: PgTransaction, + ids: number[], + data: Partial +) { + return tx + .update(items) + .set(data) + .where(inArray(items.id, ids)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +export async function findAllItems(): Promise { + return db.select().from(items).orderBy(asc(items.itemCode)); +} diff --git a/lib/items/service.ts b/lib/items/service.ts new file mode 100644 index 00000000..ef14a5f0 --- /dev/null +++ b/lib/items/service.ts @@ -0,0 +1,201 @@ +// src/lib/items/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { customAlphabet } from "nanoid"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations"; +import { Item, items } from "@/db/schema/items"; +import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository"; + + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getItems(input: GetItemsSchema) { + + 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: items, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(items.itemCode, s), ilike(items.itemName, s) + , ilike(items.description, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(items[item.id]) : asc(items[item.id]) + ) + : [asc(items.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectItems(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countItems(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["items"], // revalidateTag("items") 호출 시 무효화 + } + )(); +} + + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + + +/** + * Item 생성 후, (가장 오래된 Item 1개) 삭제로 + * 전체 Item 개수를 고정 + */ +export async function createItem(input: CreateItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // 새 Item 생성 + const [newTask] = await insertItem(tx, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }); + return newTask; + + }); + + // 캐시 무효화 + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyItem(input: UpdateItemSchema & { id: number }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateItem(tx, input.id, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }); + return res; + }); + + revalidateTag("items"); + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + + +/** 단건 삭제 */ +export async function removeItem(input: { id: number }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteItemById(tx, input.id); + // 바로 새 Item 생성 + }); + + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeItems(input: { ids: number[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteItemsByIds(tx, input.ids); + }); + + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getAllItems(): Promise { + try { + return await findAllItems(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} diff --git a/lib/items/table/add-items-dialog.tsx b/lib/items/table/add-items-dialog.tsx new file mode 100644 index 00000000..2224444c --- /dev/null +++ b/lib/items/table/add-items-dialog.tsx @@ -0,0 +1,156 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +// shadcn/ui Select +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { createItemSchema, CreateItemSchema } from "../validations" +import { createItem } from "../service" +import { Textarea } from "@/components/ui/textarea" + + + +export function AddItemDialog() { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm({ + resolver: zodResolver(createItemSchema), + defaultValues: { + itemCode: "", + itemName: "", + description: "", + }, + }) + + async function onSubmit(data: CreateItemSchema) { + const result = await createItem(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + + {/* 모달을 열기 위한 버튼 */} + + + + + + + Create New Item + + 새 Item 정보를 입력하고 Create 버튼을 누르세요. + + + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} +
+ +
+ + ( + + Item Code + + + + + + )} + /> + ( + + Item Name + + + + + + )} + /> + + ( + + Description + +