create-ej-app 26.0.0 → 28.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +79 -29
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -1
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -39
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +1 -0
- package/template/saas-starter/.env.example +15 -15
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/README.md +124 -2
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/eslint.config.mjs +6 -5
- package/template/saas-starter/gitignore +6 -1
- package/template/saas-starter/next.config.ts +19 -1
- package/template/saas-starter/package.json +50 -44
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
- package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
- package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
- package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +29 -0
- package/template/saas-starter/src/app/layout.tsx +43 -19
- package/template/saas-starter/src/app/not-found.tsx +25 -3
- package/template/saas-starter/src/app/robots.ts +14 -10
- package/template/saas-starter/src/app/sitemap.ts +2 -1
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +65 -64
- package/template/saas-starter/src/components/data-table/types.ts +5 -4
- package/template/saas-starter/src/components/error.tsx +45 -0
- package/template/saas-starter/src/components/file-upload.tsx +65 -26
- package/template/saas-starter/src/components/loader.tsx +18 -2
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +2 -2
- package/template/saas-starter/src/components/page-heading.tsx +6 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +36 -0
- package/template/saas-starter/src/components/site-footer.tsx +20 -4
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +48 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/badge.tsx +6 -1
- package/template/saas-starter/src/components/ui/button.tsx +2 -0
- package/template/saas-starter/src/components/ui/command.tsx +1 -1
- package/template/saas-starter/src/components/user-menu.tsx +147 -24
- package/template/saas-starter/src/db/common.ts +33 -1
- package/template/saas-starter/src/db/enums.ts +24 -0
- package/template/saas-starter/src/db/index.ts +14 -5
- package/template/saas-starter/src/db/relations.ts +40 -3
- package/template/saas-starter/src/db/schema.ts +186 -35
- package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
- package/template/saas-starter/src/emails/invitation.tsx +34 -21
- package/template/saas-starter/src/emails/magic-link.tsx +15 -13
- package/template/saas-starter/src/emails/test.tsx +11 -9
- package/template/saas-starter/src/emails/welcome.tsx +12 -9
- package/template/saas-starter/src/env.ts +1 -1
- package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
- package/template/saas-starter/src/instrumentation.ts +20 -2
- package/template/saas-starter/src/lib/actions.ts +34 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
- package/template/saas-starter/src/lib/app-navigation.ts +45 -41
- package/template/saas-starter/src/lib/auth-client.ts +8 -3
- package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
- package/template/saas-starter/src/lib/auth.ts +122 -21
- package/template/saas-starter/src/lib/billing.ts +59 -24
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/constants.ts +78 -9
- package/template/saas-starter/src/lib/delay.ts +10 -0
- package/template/saas-starter/src/lib/email-service.ts +30 -1
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +22 -24
- package/template/saas-starter/src/lib/format-date.ts +69 -6
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +27 -3
- package/template/saas-starter/src/lib/organization-access.ts +56 -76
- package/template/saas-starter/src/lib/otel.ts +92 -2
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +40 -13
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
- package/template/saas-starter/src/lib/safe-action.ts +137 -17
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +17 -1
- package/template/saas-starter/src/lib/storage.ts +176 -46
- package/template/saas-starter/src/lib/stripe.ts +4 -1
- package/template/saas-starter/src/lib/toast-message.ts +87 -1
- package/template/saas-starter/src/lib/validations.ts +85 -2
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/seed.ts +82 -8
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
- package/template/saas-starter/src/scripts/test-mail.ts +28 -20
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +1 -0
- package/cli.js +0 -147
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
- package/template/saas-starter/next-env.d.ts +0 -6
- package/template/saas-starter/src/actions/uploads.ts +0 -28
- package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
- package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
- package/template/saas-starter/src/app/proxy.ts +0 -28
- package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
- package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Generic DataTable built on TanStack Table.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Server-side (manual) or client-side pagination & sorting
|
|
6
|
+
* - Toolbar search input
|
|
7
|
+
* - Static and async (server-fetched) column filters
|
|
8
|
+
* - Column visibility toggle
|
|
9
|
+
* - Skeleton loading states
|
|
10
|
+
* - Active-filter pill bar with individual / bulk clear
|
|
11
|
+
*
|
|
12
|
+
* @module components/data-table
|
|
13
|
+
*/
|
|
14
|
+
'use client'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ColumnVisibilityState,
|
|
18
|
+
type RowData,
|
|
19
|
+
type SortingState,
|
|
20
|
+
useTable
|
|
21
|
+
} from '@tanstack/react-table'
|
|
22
|
+
import {
|
|
23
|
+
ArrowDown,
|
|
24
|
+
ArrowUp,
|
|
25
|
+
ArrowUpDown,
|
|
26
|
+
Check,
|
|
27
|
+
ChevronLeft,
|
|
28
|
+
ChevronRight,
|
|
29
|
+
ChevronsLeft,
|
|
30
|
+
ChevronsRight,
|
|
31
|
+
Filter,
|
|
32
|
+
Loader2,
|
|
33
|
+
Search,
|
|
34
|
+
SlidersHorizontal,
|
|
35
|
+
X
|
|
36
|
+
} from 'lucide-react'
|
|
37
|
+
import React, {
|
|
38
|
+
memo,
|
|
39
|
+
useCallback,
|
|
40
|
+
useEffect,
|
|
41
|
+
useId,
|
|
42
|
+
useMemo,
|
|
43
|
+
useRef,
|
|
44
|
+
useState
|
|
45
|
+
} from 'react'
|
|
46
|
+
import { Badge } from '~/components/ui/badge'
|
|
47
|
+
import { Button } from '~/components/ui/button'
|
|
48
|
+
import {
|
|
49
|
+
Command,
|
|
50
|
+
CommandEmpty,
|
|
51
|
+
CommandGroup,
|
|
52
|
+
CommandInput,
|
|
53
|
+
CommandItem,
|
|
54
|
+
CommandList
|
|
55
|
+
} from '~/components/ui/command'
|
|
56
|
+
import {
|
|
57
|
+
DropdownMenu,
|
|
58
|
+
DropdownMenuCheckboxItem,
|
|
59
|
+
DropdownMenuContent,
|
|
60
|
+
DropdownMenuItem,
|
|
61
|
+
DropdownMenuLabel,
|
|
62
|
+
DropdownMenuSeparator,
|
|
63
|
+
DropdownMenuSub,
|
|
64
|
+
DropdownMenuSubContent,
|
|
65
|
+
DropdownMenuSubTrigger,
|
|
66
|
+
DropdownMenuTrigger
|
|
67
|
+
} from '~/components/ui/dropdown-menu'
|
|
68
|
+
import { Input } from '~/components/ui/input'
|
|
69
|
+
import {
|
|
70
|
+
Select,
|
|
71
|
+
SelectContent,
|
|
72
|
+
SelectItem,
|
|
73
|
+
SelectTrigger,
|
|
74
|
+
SelectValue
|
|
75
|
+
} from '~/components/ui/select'
|
|
76
|
+
import {
|
|
77
|
+
Table,
|
|
78
|
+
TableBody,
|
|
79
|
+
TableCell,
|
|
80
|
+
TableHead,
|
|
81
|
+
TableHeader,
|
|
82
|
+
TableRow
|
|
83
|
+
} from '~/components/ui/table'
|
|
84
|
+
import {
|
|
85
|
+
Tooltip,
|
|
86
|
+
TooltipContent,
|
|
87
|
+
TooltipTrigger
|
|
88
|
+
} from '~/components/ui/tooltip'
|
|
89
|
+
import { cn } from '~/lib/cn'
|
|
90
|
+
import { useSafeActionQuery } from '~/lib/safe-action-client'
|
|
91
|
+
import { Skeleton } from '../ui/skeleton'
|
|
92
|
+
import { SortOrderEnum } from './enum'
|
|
93
|
+
import { dataTableFeatures } from './features'
|
|
94
|
+
import { DataTableFilter, DataTableProps } from './types'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Filter sub-content for filters with pre-defined static options.
|
|
98
|
+
* Renders a searchable command list with checkboxes for multi-select.
|
|
99
|
+
*/
|
|
100
|
+
const StaticFilterSubContent = memo(function StaticFilterSubContent({
|
|
101
|
+
filter,
|
|
102
|
+
activeFilters,
|
|
103
|
+
onSelect
|
|
104
|
+
}: {
|
|
105
|
+
filter: DataTableFilter
|
|
106
|
+
activeFilters: Record<string, (string | boolean)[]>
|
|
107
|
+
onSelect: (values: (string | boolean)[]) => void
|
|
108
|
+
}) {
|
|
109
|
+
// Convert all selected values to strings for uniform Set comparison
|
|
110
|
+
const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
|
|
111
|
+
return (
|
|
112
|
+
<Command>
|
|
113
|
+
<CommandInput placeholder={filter.label} />
|
|
114
|
+
<CommandList>
|
|
115
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
116
|
+
<CommandGroup>
|
|
117
|
+
{filter.options?.map(option => {
|
|
118
|
+
const isSelected = selectedValues.has(String(option.value))
|
|
119
|
+
const isTruncated = option.label && option.label.length > 18
|
|
120
|
+
return (
|
|
121
|
+
<CommandItem
|
|
122
|
+
key={String(option.value)}
|
|
123
|
+
className="data-selected:bg-accent data-selected:text-accent-foreground"
|
|
124
|
+
onSelect={() => {
|
|
125
|
+
const newSet = new Set(selectedValues)
|
|
126
|
+
if (isSelected) {
|
|
127
|
+
newSet.delete(String(option.value))
|
|
128
|
+
} else {
|
|
129
|
+
newSet.add(String(option.value))
|
|
130
|
+
}
|
|
131
|
+
// Map back to original values
|
|
132
|
+
const values = Array.from(newSet).map(v => {
|
|
133
|
+
const op = filter.options?.find(o => String(o.value) === v)
|
|
134
|
+
return op ? op.value : v
|
|
135
|
+
})
|
|
136
|
+
onSelect(values)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<div
|
|
140
|
+
className={cn(
|
|
141
|
+
'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
|
|
142
|
+
isSelected
|
|
143
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
144
|
+
: 'border-input bg-background text-transparent'
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
<Check
|
|
148
|
+
className={cn(
|
|
149
|
+
'size-3',
|
|
150
|
+
isSelected
|
|
151
|
+
? 'text-primary-foreground!'
|
|
152
|
+
: 'text-transparent'
|
|
153
|
+
)}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
{option.icon && (
|
|
157
|
+
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
|
158
|
+
)}
|
|
159
|
+
{isTruncated ? (
|
|
160
|
+
<Tooltip>
|
|
161
|
+
<TooltipTrigger asChild>
|
|
162
|
+
<span>{option.label.slice(0, 18) + '…'}</span>
|
|
163
|
+
</TooltipTrigger>
|
|
164
|
+
<TooltipContent side="right">
|
|
165
|
+
<p className="max-w-75 break-all">{option.label}</p>
|
|
166
|
+
</TooltipContent>
|
|
167
|
+
</Tooltip>
|
|
168
|
+
) : (
|
|
169
|
+
<span>{option.label}</span>
|
|
170
|
+
)}
|
|
171
|
+
</CommandItem>
|
|
172
|
+
)
|
|
173
|
+
})}
|
|
174
|
+
</CommandGroup>
|
|
175
|
+
</CommandList>
|
|
176
|
+
</Command>
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Filter sub-content for async filters whose options are fetched
|
|
182
|
+
* from the server via a safe-action. Includes debounced search input.
|
|
183
|
+
*/
|
|
184
|
+
const AsyncFilterSubContent = memo(function AsyncFilterSubContent({
|
|
185
|
+
filter,
|
|
186
|
+
activeFilters,
|
|
187
|
+
onSelect,
|
|
188
|
+
onCacheLabels
|
|
189
|
+
}: {
|
|
190
|
+
filter: DataTableFilter
|
|
191
|
+
activeFilters: Record<string, (string | boolean)[]>
|
|
192
|
+
onSelect: (values: string[]) => void
|
|
193
|
+
onCacheLabels: (labels: Record<string, string>) => void
|
|
194
|
+
}) {
|
|
195
|
+
const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
|
|
196
|
+
const [search, setSearch] = useState('')
|
|
197
|
+
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
198
|
+
|
|
199
|
+
// Debounce the search input to avoid excessive server calls
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const timer = setTimeout(() => setDebouncedSearch(search), 500)
|
|
202
|
+
return () => clearTimeout(timer)
|
|
203
|
+
}, [search])
|
|
204
|
+
|
|
205
|
+
// Fetch filter options from the server action with debounced search
|
|
206
|
+
const { data, isPending } = useSafeActionQuery(
|
|
207
|
+
filter.queryKey || filter.id,
|
|
208
|
+
filter.action,
|
|
209
|
+
{
|
|
210
|
+
search: debouncedSearch,
|
|
211
|
+
limit: 20,
|
|
212
|
+
page: 1
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
// Normalise various server response shapes into a flat array
|
|
217
|
+
const items = useMemo<{ label: string; value: string | number | boolean }[]>(
|
|
218
|
+
() =>
|
|
219
|
+
(data || []).map((item: any) => ({
|
|
220
|
+
label: item.label ?? item.name ?? item.id,
|
|
221
|
+
value: item.value ?? item.id
|
|
222
|
+
})),
|
|
223
|
+
[data]
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Command shouldFilter={false}>
|
|
228
|
+
<CommandInput
|
|
229
|
+
placeholder={filter.label}
|
|
230
|
+
value={search}
|
|
231
|
+
onValueChange={setSearch}
|
|
232
|
+
/>
|
|
233
|
+
<CommandList>
|
|
234
|
+
{isPending && (
|
|
235
|
+
<div className="flex items-center justify-center py-4">
|
|
236
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{!isPending && items.length === 0 && (
|
|
240
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
241
|
+
)}
|
|
242
|
+
<CommandGroup>
|
|
243
|
+
{items.map(item => {
|
|
244
|
+
const value = String(item.value)
|
|
245
|
+
const label = item.label
|
|
246
|
+
const isSelected = selectedValues.has(value)
|
|
247
|
+
const isTruncated = label && label.length > 18
|
|
248
|
+
return (
|
|
249
|
+
<CommandItem
|
|
250
|
+
key={value}
|
|
251
|
+
className="data-selected:bg-accent data-selected:text-accent-foreground"
|
|
252
|
+
onSelect={() => {
|
|
253
|
+
const newSet = new Set(selectedValues)
|
|
254
|
+
if (isSelected) {
|
|
255
|
+
newSet.delete(value)
|
|
256
|
+
} else {
|
|
257
|
+
newSet.add(value)
|
|
258
|
+
// Update cache when selecting an item
|
|
259
|
+
onCacheLabels({ [value]: label })
|
|
260
|
+
}
|
|
261
|
+
onSelect(Array.from(newSet))
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div
|
|
265
|
+
className={cn(
|
|
266
|
+
'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
|
|
267
|
+
isSelected
|
|
268
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
269
|
+
: 'border-input bg-background text-transparent'
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<Check
|
|
273
|
+
className={cn(
|
|
274
|
+
'size-3',
|
|
275
|
+
isSelected
|
|
276
|
+
? 'text-primary-foreground!'
|
|
277
|
+
: 'text-transparent'
|
|
278
|
+
)}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
{/* Truncate long labels for better readability */}
|
|
282
|
+
{isTruncated ? (
|
|
283
|
+
<Tooltip>
|
|
284
|
+
<TooltipTrigger asChild>
|
|
285
|
+
<span>{label.slice(0, 18) + '…'}</span>
|
|
286
|
+
</TooltipTrigger>
|
|
287
|
+
<TooltipContent side="right">
|
|
288
|
+
<p className="max-w-75 break-all">{label}</p>
|
|
289
|
+
</TooltipContent>
|
|
290
|
+
</Tooltip>
|
|
291
|
+
) : (
|
|
292
|
+
<span>{label}</span>
|
|
293
|
+
)}
|
|
294
|
+
</CommandItem>
|
|
295
|
+
)
|
|
296
|
+
})}
|
|
297
|
+
</CommandGroup>
|
|
298
|
+
</CommandList>
|
|
299
|
+
</Command>
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generic DataTable component with toolbar, filters, sorting and pagination.
|
|
305
|
+
*
|
|
306
|
+
* @template TData Row data shape
|
|
307
|
+
*/
|
|
308
|
+
export function DataTable<TData extends RowData>({
|
|
309
|
+
columns,
|
|
310
|
+
data = [],
|
|
311
|
+
isLoading = false,
|
|
312
|
+
enableSearch = false,
|
|
313
|
+
searchPlaceholder = 'Search…',
|
|
314
|
+
initialSearch = '',
|
|
315
|
+
totalCount,
|
|
316
|
+
onParamsChange,
|
|
317
|
+
manualPagination = false,
|
|
318
|
+
manualSorting = false,
|
|
319
|
+
pageIndex: controlledPageIndex = 0,
|
|
320
|
+
pageSize: controlledPageSize = 10,
|
|
321
|
+
initialSorting = [],
|
|
322
|
+
filters = [],
|
|
323
|
+
activeFilters = {}
|
|
324
|
+
}: DataTableProps<TData>) {
|
|
325
|
+
const [sorting, setSorting] = useState<SortingState>(initialSorting)
|
|
326
|
+
const [columnVisibility, setColumnVisibility] =
|
|
327
|
+
useState<ColumnVisibilityState>({})
|
|
328
|
+
const [searchValue, setSearchValue] = useState(initialSearch)
|
|
329
|
+
const [pagination, setPagination] = useState({
|
|
330
|
+
pageIndex: controlledPageIndex,
|
|
331
|
+
pageSize: controlledPageSize
|
|
332
|
+
})
|
|
333
|
+
/** Cache for async filter labels so pills display correctly after data refetch. */
|
|
334
|
+
const [labelCache, setLabelCache] = useState<Record<string, string>>({})
|
|
335
|
+
const searchInputId = useId()
|
|
336
|
+
/**
|
|
337
|
+
* Last positive totalCount. Used while loading so a transient 0 does not
|
|
338
|
+
* collapse the pagination footer (e.g. "Page 1 of 1" mid-refetch).
|
|
339
|
+
* Adjusted during render when the prop changes (React-approved pattern).
|
|
340
|
+
*/
|
|
341
|
+
const [lastPositiveTotalCount, setLastPositiveTotalCount] = useState<
|
|
342
|
+
number | undefined
|
|
343
|
+
>(totalCount !== undefined && totalCount > 0 ? totalCount : undefined)
|
|
344
|
+
if (
|
|
345
|
+
totalCount !== undefined &&
|
|
346
|
+
totalCount > 0 &&
|
|
347
|
+
totalCount !== lastPositiveTotalCount
|
|
348
|
+
) {
|
|
349
|
+
setLastPositiveTotalCount(totalCount)
|
|
350
|
+
}
|
|
351
|
+
const stableTotalCount =
|
|
352
|
+
isLoading && totalCount === 0 && lastPositiveTotalCount !== undefined
|
|
353
|
+
? lastPositiveTotalCount
|
|
354
|
+
: totalCount
|
|
355
|
+
|
|
356
|
+
/** Latest values for debounced/async callbacks without putting them in effect deps. */
|
|
357
|
+
const activeFiltersRef = useRef(activeFilters)
|
|
358
|
+
const onParamsChangeRef = useRef(onParamsChange)
|
|
359
|
+
/** Set by the prop-sync effect so the debounced effect can skip redundant fires. */
|
|
360
|
+
const isExternalPaginationSync = useRef(false)
|
|
361
|
+
/** Skip the initial render to avoid firing onParamsChange on mount. */
|
|
362
|
+
const isFirstRender = useRef(true)
|
|
363
|
+
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
activeFiltersRef.current = activeFilters
|
|
366
|
+
}, [activeFilters])
|
|
367
|
+
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
onParamsChangeRef.current = onParamsChange
|
|
370
|
+
}, [onParamsChange])
|
|
371
|
+
|
|
372
|
+
// Keep internal pagination in sync with controlled props from parent.
|
|
373
|
+
// Without this, external resets (e.g. filter change → page 1) are ignored.
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
setPagination(prev => {
|
|
376
|
+
if (
|
|
377
|
+
prev.pageIndex !== controlledPageIndex ||
|
|
378
|
+
prev.pageSize !== controlledPageSize
|
|
379
|
+
) {
|
|
380
|
+
// Flag so the debounced effect skips its next fire: the filter/clear
|
|
381
|
+
// handler that triggered this prop change already called onParamsChange.
|
|
382
|
+
isExternalPaginationSync.current = true
|
|
383
|
+
return {
|
|
384
|
+
pageIndex: controlledPageIndex,
|
|
385
|
+
pageSize: controlledPageSize
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return prev
|
|
389
|
+
})
|
|
390
|
+
}, [controlledPageIndex, controlledPageSize])
|
|
391
|
+
|
|
392
|
+
// TanStack Table v9: features (and row models) live on `dataTableFeatures`.
|
|
393
|
+
// Core row model is always included; sorted/paginated models only run when
|
|
394
|
+
// manual* is false.
|
|
395
|
+
const table = useTable({
|
|
396
|
+
features: dataTableFeatures,
|
|
397
|
+
data,
|
|
398
|
+
columns,
|
|
399
|
+
manualPagination,
|
|
400
|
+
manualSorting,
|
|
401
|
+
...(manualPagination && {
|
|
402
|
+
pageCount:
|
|
403
|
+
Math.ceil((stableTotalCount ?? data.length) / pagination.pageSize) || 1
|
|
404
|
+
}),
|
|
405
|
+
onSortingChange: setSorting,
|
|
406
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
407
|
+
onPaginationChange: setPagination,
|
|
408
|
+
state: {
|
|
409
|
+
sorting,
|
|
410
|
+
columnVisibility,
|
|
411
|
+
pagination
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the params object sent to onParamsChange.
|
|
417
|
+
* Uses `activeFiltersRef` so debounced callbacks always read the latest filters
|
|
418
|
+
* without putting `activeFilters` in the debounce effect deps (filter handlers
|
|
419
|
+
* already call onParamsChange themselves).
|
|
420
|
+
* Pass `overrides` to set page or filters explicitly (e.g. on filter change).
|
|
421
|
+
*/
|
|
422
|
+
const buildParams = useCallback(
|
|
423
|
+
(
|
|
424
|
+
overrides?: Partial<{
|
|
425
|
+
page: number
|
|
426
|
+
filters: Record<string, (string | boolean)[]> | undefined
|
|
427
|
+
}>
|
|
428
|
+
) => {
|
|
429
|
+
const sort = sorting[0]
|
|
430
|
+
const filtersValue =
|
|
431
|
+
overrides && 'filters' in overrides
|
|
432
|
+
? overrides.filters
|
|
433
|
+
: activeFiltersRef.current &&
|
|
434
|
+
Object.keys(activeFiltersRef.current).length > 0
|
|
435
|
+
? activeFiltersRef.current
|
|
436
|
+
: undefined
|
|
437
|
+
return {
|
|
438
|
+
page: overrides?.page ?? pagination.pageIndex + 1,
|
|
439
|
+
limit: pagination.pageSize,
|
|
440
|
+
sortBy: sort?.id,
|
|
441
|
+
sortOrder: sort
|
|
442
|
+
? sort.desc
|
|
443
|
+
? SortOrderEnum.DESC
|
|
444
|
+
: SortOrderEnum.ASC
|
|
445
|
+
: undefined,
|
|
446
|
+
search: searchValue || undefined,
|
|
447
|
+
filters: filtersValue
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
[pagination.pageIndex, pagination.pageSize, searchValue, sorting]
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
// Debounce param changes (300ms) to batch rapid user interactions.
|
|
454
|
+
// Only watches pagination, sorting, and searchValue, NOT activeFilters,
|
|
455
|
+
// because filter handlers already call onParamsChange directly.
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
if (isFirstRender.current) {
|
|
458
|
+
isFirstRender.current = false
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Skip when the pagination change came from an external prop sync
|
|
463
|
+
// (e.g. parent reset page after a filter handler already called onParamsChange).
|
|
464
|
+
if (isExternalPaginationSync.current) {
|
|
465
|
+
isExternalPaginationSync.current = false
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!onParamsChangeRef.current) return
|
|
470
|
+
|
|
471
|
+
const timeout = setTimeout(() => {
|
|
472
|
+
onParamsChangeRef.current?.(buildParams())
|
|
473
|
+
}, 300)
|
|
474
|
+
|
|
475
|
+
return () => clearTimeout(timeout)
|
|
476
|
+
}, [pagination, sorting, searchValue, buildParams])
|
|
477
|
+
|
|
478
|
+
/** Whether any filter has at least one selected value. */
|
|
479
|
+
const hasAnyFilter = useMemo(
|
|
480
|
+
() => Object.values(activeFilters).some(f => f.length > 0),
|
|
481
|
+
[activeFilters]
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
/** Sum of all selected filter values across all filters (shown as badge count). */
|
|
485
|
+
const totalActiveFilterCount = useMemo(
|
|
486
|
+
() =>
|
|
487
|
+
Object.values(activeFilters).reduce((acc, curr) => acc + curr.length, 0),
|
|
488
|
+
[activeFilters]
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
/** Stable callback for async filter sub-components to update label cache. */
|
|
492
|
+
const handleCacheLabels = useCallback((newLabels: Record<string, string>) => {
|
|
493
|
+
setLabelCache(prev => ({ ...prev, ...newLabels }))
|
|
494
|
+
}, [])
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Stable factory for per-filter onSelect handlers.
|
|
498
|
+
* Returns a memoized handler for each filter id so sub-components
|
|
499
|
+
* receive a stable reference and avoid unnecessary re-renders.
|
|
500
|
+
*/
|
|
501
|
+
const makeFilterSelectHandler = useCallback(
|
|
502
|
+
(filterId: string) => (values: (string | boolean)[]) => {
|
|
503
|
+
const newFilters: Record<string, (string | boolean)[]> = {
|
|
504
|
+
...(activeFilters || {})
|
|
505
|
+
}
|
|
506
|
+
if (values.length > 0) {
|
|
507
|
+
newFilters[filterId] = values
|
|
508
|
+
} else {
|
|
509
|
+
delete newFilters[filterId]
|
|
510
|
+
}
|
|
511
|
+
onParamsChange?.(
|
|
512
|
+
buildParams({
|
|
513
|
+
page: 1,
|
|
514
|
+
filters: Object.keys(newFilters).length > 0 ? newFilters : undefined
|
|
515
|
+
})
|
|
516
|
+
)
|
|
517
|
+
},
|
|
518
|
+
[activeFilters, buildParams, onParamsChange]
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<div className="space-y-4">
|
|
523
|
+
{/* ── Toolbar: search, filter dropdown, column visibility toggle ── */}
|
|
524
|
+
<div className="flex items-center justify-between gap-2">
|
|
525
|
+
<div className="flex flex-1 items-center gap-2">
|
|
526
|
+
{enableSearch && (
|
|
527
|
+
<div className="relative max-w-sm flex-1">
|
|
528
|
+
<Search
|
|
529
|
+
className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4"
|
|
530
|
+
aria-hidden
|
|
531
|
+
/>
|
|
532
|
+
<label htmlFor={searchInputId} className="sr-only">
|
|
533
|
+
{searchPlaceholder}
|
|
534
|
+
</label>
|
|
535
|
+
<Input
|
|
536
|
+
id={searchInputId}
|
|
537
|
+
type="search"
|
|
538
|
+
placeholder={searchPlaceholder}
|
|
539
|
+
value={searchValue}
|
|
540
|
+
onChange={event => {
|
|
541
|
+
setSearchValue(event.target.value)
|
|
542
|
+
// Reset to first page when search query changes
|
|
543
|
+
setPagination(prev => ({ ...prev, pageIndex: 0 }))
|
|
544
|
+
}}
|
|
545
|
+
className="pl-8"
|
|
546
|
+
aria-label={searchPlaceholder}
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
{filters.length > 0 && (
|
|
551
|
+
<DropdownMenu>
|
|
552
|
+
<DropdownMenuTrigger asChild>
|
|
553
|
+
<Button
|
|
554
|
+
variant="outline"
|
|
555
|
+
size="sm"
|
|
556
|
+
className="gap-1"
|
|
557
|
+
aria-label="Filter"
|
|
558
|
+
>
|
|
559
|
+
<Filter className="h-4 w-4" />
|
|
560
|
+
{totalActiveFilterCount > 0 && (
|
|
561
|
+
<Badge
|
|
562
|
+
variant="secondary"
|
|
563
|
+
className="pointer-events-none ml-1 h-5 min-w-5 px-1"
|
|
564
|
+
>
|
|
565
|
+
{totalActiveFilterCount}
|
|
566
|
+
</Badge>
|
|
567
|
+
)}
|
|
568
|
+
</Button>
|
|
569
|
+
</DropdownMenuTrigger>
|
|
570
|
+
<DropdownMenuContent align="start" className="w-50">
|
|
571
|
+
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
|
572
|
+
<DropdownMenuSeparator />
|
|
573
|
+
{/* Render each filter as a sub-menu with either async or static content */}
|
|
574
|
+
{filters.map(filter => (
|
|
575
|
+
<DropdownMenuSub key={filter.id}>
|
|
576
|
+
<DropdownMenuSubTrigger>
|
|
577
|
+
{filter.label}
|
|
578
|
+
</DropdownMenuSubTrigger>
|
|
579
|
+
<DropdownMenuSubContent className="w-50 p-0">
|
|
580
|
+
{filter.action ? (
|
|
581
|
+
<AsyncFilterSubContent
|
|
582
|
+
filter={filter}
|
|
583
|
+
activeFilters={activeFilters}
|
|
584
|
+
onCacheLabels={handleCacheLabels}
|
|
585
|
+
onSelect={
|
|
586
|
+
makeFilterSelectHandler(filter.id) as (
|
|
587
|
+
values: string[]
|
|
588
|
+
) => void
|
|
589
|
+
}
|
|
590
|
+
/>
|
|
591
|
+
) : (
|
|
592
|
+
<StaticFilterSubContent
|
|
593
|
+
filter={filter}
|
|
594
|
+
activeFilters={activeFilters}
|
|
595
|
+
onSelect={makeFilterSelectHandler(filter.id)}
|
|
596
|
+
/>
|
|
597
|
+
)}
|
|
598
|
+
</DropdownMenuSubContent>
|
|
599
|
+
</DropdownMenuSub>
|
|
600
|
+
))}
|
|
601
|
+
{hasAnyFilter && (
|
|
602
|
+
<>
|
|
603
|
+
<DropdownMenuSeparator />
|
|
604
|
+
<DropdownMenuItem
|
|
605
|
+
onSelect={() => {
|
|
606
|
+
onParamsChange?.(
|
|
607
|
+
buildParams({ page: 1, filters: undefined })
|
|
608
|
+
)
|
|
609
|
+
}}
|
|
610
|
+
className="justify-center text-center"
|
|
611
|
+
>
|
|
612
|
+
Clear Filters
|
|
613
|
+
</DropdownMenuItem>
|
|
614
|
+
</>
|
|
615
|
+
)}
|
|
616
|
+
</DropdownMenuContent>
|
|
617
|
+
</DropdownMenu>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
{/* Column visibility toggle dropdown */}
|
|
621
|
+
<DropdownMenu>
|
|
622
|
+
<DropdownMenuTrigger asChild>
|
|
623
|
+
<Button
|
|
624
|
+
variant="outline"
|
|
625
|
+
size="sm"
|
|
626
|
+
className="gap-1"
|
|
627
|
+
aria-label="Toggle columns"
|
|
628
|
+
>
|
|
629
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
630
|
+
</Button>
|
|
631
|
+
</DropdownMenuTrigger>
|
|
632
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
633
|
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
|
634
|
+
<DropdownMenuSeparator />
|
|
635
|
+
{table
|
|
636
|
+
.getAllColumns()
|
|
637
|
+
.filter(column => column.getCanHide())
|
|
638
|
+
.map(column => {
|
|
639
|
+
return (
|
|
640
|
+
<DropdownMenuCheckboxItem
|
|
641
|
+
key={column.id}
|
|
642
|
+
className="capitalize"
|
|
643
|
+
checked={column.getIsVisible()}
|
|
644
|
+
onCheckedChange={value => column.toggleVisibility(!!value)}
|
|
645
|
+
onSelect={e => e.preventDefault()}
|
|
646
|
+
>
|
|
647
|
+
{column.id}
|
|
648
|
+
</DropdownMenuCheckboxItem>
|
|
649
|
+
)
|
|
650
|
+
})}
|
|
651
|
+
</DropdownMenuContent>
|
|
652
|
+
</DropdownMenu>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
{/* ── Active filter pills bar ── */}
|
|
656
|
+
{hasAnyFilter && (
|
|
657
|
+
<div className="flex flex-wrap gap-2">
|
|
658
|
+
{filters.map(filter => {
|
|
659
|
+
const selected = activeFilters[filter.id]
|
|
660
|
+
if (!selected || selected.length === 0) return null
|
|
661
|
+
return (
|
|
662
|
+
<div
|
|
663
|
+
key={filter.id}
|
|
664
|
+
className="bg-accent/50 flex items-center gap-1 rounded-md px-2 py-1 text-sm"
|
|
665
|
+
>
|
|
666
|
+
<span className="font-medium">{filter.label}:</span>
|
|
667
|
+
<div className="flex gap-1">
|
|
668
|
+
{selected.length > 2 ? (
|
|
669
|
+
<Badge variant="secondary" className="px-1 font-normal">
|
|
670
|
+
{selected.length} selected
|
|
671
|
+
</Badge>
|
|
672
|
+
) : (
|
|
673
|
+
selected.map(val => {
|
|
674
|
+
const displayLabel =
|
|
675
|
+
filter.options?.find(
|
|
676
|
+
o => String(o.value) === String(val)
|
|
677
|
+
)?.label ||
|
|
678
|
+
labelCache[String(val)] ||
|
|
679
|
+
String(val)
|
|
680
|
+
const isTruncated =
|
|
681
|
+
displayLabel && displayLabel.length > 18
|
|
682
|
+
return isTruncated ? (
|
|
683
|
+
<Tooltip key={String(val)}>
|
|
684
|
+
<TooltipTrigger asChild>
|
|
685
|
+
<Badge
|
|
686
|
+
variant="secondary"
|
|
687
|
+
className="px-1 font-normal"
|
|
688
|
+
>
|
|
689
|
+
{displayLabel.slice(0, 18) + '…'}
|
|
690
|
+
</Badge>
|
|
691
|
+
</TooltipTrigger>
|
|
692
|
+
<TooltipContent>
|
|
693
|
+
<p className="max-w-75 break-all">{displayLabel}</p>
|
|
694
|
+
</TooltipContent>
|
|
695
|
+
</Tooltip>
|
|
696
|
+
) : (
|
|
697
|
+
<Badge
|
|
698
|
+
key={String(val)}
|
|
699
|
+
variant="secondary"
|
|
700
|
+
className="px-1 font-normal"
|
|
701
|
+
>
|
|
702
|
+
{displayLabel}
|
|
703
|
+
</Badge>
|
|
704
|
+
)
|
|
705
|
+
})
|
|
706
|
+
)}
|
|
707
|
+
</div>
|
|
708
|
+
<Button
|
|
709
|
+
variant="ghost"
|
|
710
|
+
className="hover:bg-muted ml-1 h-4 w-4 rounded-full p-0"
|
|
711
|
+
aria-label={`Clear ${filter.label} filter`}
|
|
712
|
+
onClick={() => {
|
|
713
|
+
const newFilters = { ...activeFilters }
|
|
714
|
+
delete newFilters[filter.id]
|
|
715
|
+
onParamsChange?.(
|
|
716
|
+
buildParams({
|
|
717
|
+
page: 1,
|
|
718
|
+
filters:
|
|
719
|
+
Object.keys(newFilters).length > 0
|
|
720
|
+
? newFilters
|
|
721
|
+
: undefined
|
|
722
|
+
})
|
|
723
|
+
)
|
|
724
|
+
}}
|
|
725
|
+
>
|
|
726
|
+
<X className="h-3 w-3" />
|
|
727
|
+
</Button>
|
|
728
|
+
</div>
|
|
729
|
+
)
|
|
730
|
+
})}
|
|
731
|
+
<Button
|
|
732
|
+
variant="ghost"
|
|
733
|
+
className="h-7 px-2 text-xs"
|
|
734
|
+
onClick={() => {
|
|
735
|
+
onParamsChange?.(buildParams({ page: 1, filters: undefined }))
|
|
736
|
+
}}
|
|
737
|
+
>
|
|
738
|
+
Reset
|
|
739
|
+
</Button>
|
|
740
|
+
</div>
|
|
741
|
+
)}
|
|
742
|
+
|
|
743
|
+
{/* ── Table body ── */}
|
|
744
|
+
<div className="bg-card relative rounded-md border">
|
|
745
|
+
{isLoading && data.length > 0 && (
|
|
746
|
+
<div
|
|
747
|
+
role="status"
|
|
748
|
+
aria-label="Loading"
|
|
749
|
+
aria-live="polite"
|
|
750
|
+
className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center rounded-md"
|
|
751
|
+
>
|
|
752
|
+
<Loader2
|
|
753
|
+
className="text-muted-foreground h-6 w-6 animate-spin"
|
|
754
|
+
aria-hidden
|
|
755
|
+
/>
|
|
756
|
+
</div>
|
|
757
|
+
)}
|
|
758
|
+
<Table aria-busy={isLoading} aria-label="Data table">
|
|
759
|
+
<TableHeader>
|
|
760
|
+
{table.getHeaderGroups().map(headerGroup => (
|
|
761
|
+
<TableRow key={headerGroup.id}>
|
|
762
|
+
{headerGroup.headers.map(header => {
|
|
763
|
+
const isSortable = header.column.getCanSort()
|
|
764
|
+
const sortStatus = header.column.getIsSorted()
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<TableHead key={header.id} colSpan={header.colSpan}>
|
|
768
|
+
{header.isPlaceholder ? null : (
|
|
769
|
+
<div
|
|
770
|
+
role={isSortable ? 'button' : undefined}
|
|
771
|
+
tabIndex={isSortable ? 0 : undefined}
|
|
772
|
+
aria-sort={
|
|
773
|
+
isSortable
|
|
774
|
+
? sortStatus === 'asc'
|
|
775
|
+
? 'ascending'
|
|
776
|
+
: sortStatus === 'desc'
|
|
777
|
+
? 'descending'
|
|
778
|
+
: 'none'
|
|
779
|
+
: undefined
|
|
780
|
+
}
|
|
781
|
+
className={cn(
|
|
782
|
+
'flex items-center gap-2',
|
|
783
|
+
isSortable &&
|
|
784
|
+
'hover:text-foreground cursor-pointer select-none'
|
|
785
|
+
)}
|
|
786
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
787
|
+
onKeyDown={
|
|
788
|
+
isSortable
|
|
789
|
+
? e => {
|
|
790
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
791
|
+
e.preventDefault()
|
|
792
|
+
header.column.getToggleSortingHandler()?.(
|
|
793
|
+
e as unknown as React.MouseEvent
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
: undefined
|
|
798
|
+
}
|
|
799
|
+
>
|
|
800
|
+
<table.FlexRender header={header} />
|
|
801
|
+
{isSortable && (
|
|
802
|
+
<div className="flex w-4 items-center justify-center">
|
|
803
|
+
{sortStatus === 'asc' ? (
|
|
804
|
+
<ArrowUp
|
|
805
|
+
className="h-4 w-4"
|
|
806
|
+
aria-label="Sorted ascending"
|
|
807
|
+
/>
|
|
808
|
+
) : sortStatus === 'desc' ? (
|
|
809
|
+
<ArrowDown
|
|
810
|
+
className="h-4 w-4"
|
|
811
|
+
aria-label="Sorted descending"
|
|
812
|
+
/>
|
|
813
|
+
) : (
|
|
814
|
+
<ArrowUpDown
|
|
815
|
+
className="h-4 w-4 opacity-30"
|
|
816
|
+
aria-label="Not sorted"
|
|
817
|
+
/>
|
|
818
|
+
)}
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
)}
|
|
823
|
+
</TableHead>
|
|
824
|
+
)
|
|
825
|
+
})}
|
|
826
|
+
</TableRow>
|
|
827
|
+
))}
|
|
828
|
+
</TableHeader>
|
|
829
|
+
<TableBody>
|
|
830
|
+
{table.getRowModel().rows.length ? (
|
|
831
|
+
table.getRowModel().rows.map(row => (
|
|
832
|
+
<TableRow key={row.id}>
|
|
833
|
+
{row.getVisibleCells().map(cell => (
|
|
834
|
+
<TableCell key={cell.id}>
|
|
835
|
+
<table.FlexRender cell={cell} />
|
|
836
|
+
</TableCell>
|
|
837
|
+
))}
|
|
838
|
+
</TableRow>
|
|
839
|
+
))
|
|
840
|
+
) : isLoading ? (
|
|
841
|
+
Array.from({ length: pagination.pageSize }).map((_, index) => (
|
|
842
|
+
// Using array index as key is acceptable for static skeleton placeholders
|
|
843
|
+
<TableRow key={index} aria-hidden>
|
|
844
|
+
{table.getVisibleFlatColumns().map(col => (
|
|
845
|
+
<TableCell key={col.id}>
|
|
846
|
+
<Skeleton className="h-4 w-full" />
|
|
847
|
+
</TableCell>
|
|
848
|
+
))}
|
|
849
|
+
</TableRow>
|
|
850
|
+
))
|
|
851
|
+
) : (
|
|
852
|
+
<TableRow>
|
|
853
|
+
<TableCell
|
|
854
|
+
colSpan={columns.length}
|
|
855
|
+
className="h-24 text-center"
|
|
856
|
+
role="cell"
|
|
857
|
+
>
|
|
858
|
+
No results
|
|
859
|
+
</TableCell>
|
|
860
|
+
</TableRow>
|
|
861
|
+
)}
|
|
862
|
+
</TableBody>
|
|
863
|
+
</Table>
|
|
864
|
+
</div>
|
|
865
|
+
|
|
866
|
+
{/* ── Pagination footer ── */}
|
|
867
|
+
<div className="flex flex-col items-center justify-between gap-4 px-2 sm:flex-row sm:gap-0">
|
|
868
|
+
<div className="text-muted-foreground flex-1 text-center text-sm sm:text-left">
|
|
869
|
+
{stableTotalCount !== undefined ? (
|
|
870
|
+
stableTotalCount === 0 ? (
|
|
871
|
+
<>No results</>
|
|
872
|
+
) : (
|
|
873
|
+
<>
|
|
874
|
+
Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '}
|
|
875
|
+
{Math.min(
|
|
876
|
+
(pagination.pageIndex + 1) * pagination.pageSize,
|
|
877
|
+
stableTotalCount
|
|
878
|
+
)}{' '}
|
|
879
|
+
of {stableTotalCount} results
|
|
880
|
+
</>
|
|
881
|
+
)
|
|
882
|
+
) : (
|
|
883
|
+
<>{data.length} row(s) loaded.</>
|
|
884
|
+
)}
|
|
885
|
+
</div>
|
|
886
|
+
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
|
|
887
|
+
<div className="flex items-center gap-2">
|
|
888
|
+
<p className="text-sm font-medium whitespace-nowrap">
|
|
889
|
+
Rows per page
|
|
890
|
+
</p>
|
|
891
|
+
<Select
|
|
892
|
+
value={`${pagination.pageSize}`}
|
|
893
|
+
onValueChange={value => {
|
|
894
|
+
table.setPageSize(Number(value))
|
|
895
|
+
}}
|
|
896
|
+
>
|
|
897
|
+
<SelectTrigger className="h-8 w-20">
|
|
898
|
+
<SelectValue placeholder={pagination.pageSize} />
|
|
899
|
+
</SelectTrigger>
|
|
900
|
+
<SelectContent side="top">
|
|
901
|
+
{[10, 20, 50, 100].map(pageSize => (
|
|
902
|
+
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
903
|
+
{pageSize}
|
|
904
|
+
</SelectItem>
|
|
905
|
+
))}
|
|
906
|
+
</SelectContent>
|
|
907
|
+
</Select>
|
|
908
|
+
</div>
|
|
909
|
+
<div className="flex items-center gap-2">
|
|
910
|
+
<div className="flex w-25 items-center justify-center text-sm font-medium">
|
|
911
|
+
Page {pagination.pageIndex + 1} of {table.getPageCount() || 1}
|
|
912
|
+
</div>
|
|
913
|
+
<div className="flex items-center gap-1">
|
|
914
|
+
<Button
|
|
915
|
+
variant="outline"
|
|
916
|
+
className="h-8 w-8 p-0"
|
|
917
|
+
onClick={() => table.setPageIndex(0)}
|
|
918
|
+
disabled={!table.getCanPreviousPage()}
|
|
919
|
+
>
|
|
920
|
+
<span className="sr-only">Go to first page</span>
|
|
921
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
922
|
+
</Button>
|
|
923
|
+
<Button
|
|
924
|
+
variant="outline"
|
|
925
|
+
className="h-8 w-8 p-0"
|
|
926
|
+
onClick={() => table.previousPage()}
|
|
927
|
+
disabled={!table.getCanPreviousPage()}
|
|
928
|
+
>
|
|
929
|
+
<span className="sr-only">Go to previous page</span>
|
|
930
|
+
<ChevronLeft className="h-4 w-4" />
|
|
931
|
+
</Button>
|
|
932
|
+
<Button
|
|
933
|
+
variant="outline"
|
|
934
|
+
className="h-8 w-8 p-0"
|
|
935
|
+
onClick={() => table.nextPage()}
|
|
936
|
+
disabled={!table.getCanNextPage()}
|
|
937
|
+
>
|
|
938
|
+
<span className="sr-only">Go to next page</span>
|
|
939
|
+
<ChevronRight className="h-4 w-4" />
|
|
940
|
+
</Button>
|
|
941
|
+
<Button
|
|
942
|
+
variant="outline"
|
|
943
|
+
className="h-8 w-8 p-0"
|
|
944
|
+
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
945
|
+
disabled={!table.getCanNextPage()}
|
|
946
|
+
>
|
|
947
|
+
<span className="sr-only">Go to last page</span>
|
|
948
|
+
<ChevronsRight className="h-4 w-4" />
|
|
949
|
+
</Button>
|
|
950
|
+
</div>
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
</div>
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export { SortOrderEnum } from './enum'
|
|
959
|
+
export { dataTableFeatures } from './features'
|
|
960
|
+
export type { DataTableColumnDef } from './features'
|
|
961
|
+
export type { DataTableFilter, DataTableProps } from './types'
|