From eebf00f882cd8906aff2316936da9b2496018a53 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 5 May 2024 14:10:12 +0800 Subject: [PATCH] feat: add survey download feature --- pnpm-lock.yaml | 181 ++++++++++++---- .../components/survey/SurveyDownloadBtn.tsx | 199 ++++++++++++++++++ src/client/components/ui/calendar.tsx | 70 ++++++ src/client/components/ui/popover.tsx | 31 +++ src/client/components/ui/progress.tsx | 26 +++ src/client/package.json | 6 + src/client/routes/survey/$surveyId/index.tsx | 8 +- src/client/utils/dom.ts | 17 ++ src/server/trpc/routers/survey.ts | 20 +- src/server/utils/prisma.ts | 9 +- 10 files changed, 521 insertions(+), 46 deletions(-) create mode 100644 src/client/components/survey/SurveyDownloadBtn.tsx create mode 100644 src/client/components/ui/calendar.tsx create mode 100644 src/client/components/ui/popover.tsx create mode 100644 src/client/components/ui/progress.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54730d9..fc8bb8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,12 @@ importers: '@radix-ui/react-menubar': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-popover': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) @@ -200,12 +206,15 @@ importers: '@trpc/react-query': specifier: ^10.45.0 version: 10.45.0(@tanstack/react-query@4.33.0)(@trpc/client@10.45.0)(@trpc/server@10.45.0)(react-dom@18.2.0)(react@18.2.0) + '@types/jsonexport': + specifier: ^3.0.5 + version: 3.0.5 ahooks: specifier: ^3.7.10 version: 3.7.10(react@18.2.0) antd: specifier: ^5.13.1 - version: 5.13.1(react-dom@18.2.0)(react@18.2.0) + version: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) array-move: specifier: ^3.0.1 version: 3.0.1 @@ -230,6 +239,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + date-fns: + specifier: ^3.6.0 + version: 3.6.0 dayjs: specifier: 1.11.10 version: 1.11.10 @@ -242,6 +254,9 @@ importers: fuse.js: specifier: ^7.0.0 version: 7.0.0 + jsonexport: + specifier: ^3.2.0 + version: 3.2.0 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -263,6 +278,9 @@ importers: react: specifier: ^18.2.0 version: 18.2.0 + react-day-picker: + specifier: ^8.10.1 + version: 8.10.1(date-fns@3.6.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -881,7 +899,7 @@ packages: '@ant-design/icons': 5.3.6(react-dom@18.2.0)(react@18.2.0) '@ant-design/maps': 1.0.8(react-dom@18.2.0)(react@18.2.0) '@ant-design/plots': 1.2.6(react-dom@18.2.0)(react@18.2.0) - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -953,7 +971,7 @@ packages: '@antv/x6-react-components': 1.1.20(antd@5.13.1)(react-dom@18.2.0)(react@18.2.0) '@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0) '@antv/xflow': 1.1.52(@ant-design/icons@5.3.6)(antd@5.13.1)(classnames@2.5.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)(reflect-metadata@0.1.14) - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) lodash: 4.17.21 react: 18.2.0 react-color: 2.17.3(react@18.2.0) @@ -1737,7 +1755,7 @@ packages: react: '>=16.8.6 || >=17.0.0' react-dom: '>=16.8.6 || >=17.0.0' dependencies: - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) clamp: 1.0.1 classnames: 2.5.1 rc-dropdown: 3.6.2(react-dom@18.2.0)(react@18.2.0) @@ -1789,7 +1807,7 @@ packages: '@antv/x6-react-components': 1.1.20(antd@5.13.1)(react-dom@18.2.0)(react@18.2.0) '@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0) '@antv/xflow-hook': 1.0.52 - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) classnames: 2.5.1 immer: 9.0.21 lodash: 4.17.21 @@ -1820,7 +1838,7 @@ packages: '@antv/x6-react-shape': 1.6.5(@antv/x6@1.35.0)(react-dom@18.2.0)(react@18.2.0) '@antv/xflow-core': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0) '@antv/xflow-hook': 1.0.52 - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) classnames: 2.5.1 mana-syringe: 0.2.2 moment: 2.30.1 @@ -1862,7 +1880,7 @@ packages: '@antv/xflow-core': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0) '@antv/xflow-extension': 1.1.52(@ant-design/icons@5.3.6)(@antv/x6-react-components@1.1.20)(@antv/x6-react-shape@1.6.5)(@antv/x6@1.35.0)(antd@5.13.1)(classnames@2.5.1)(lodash@4.17.21)(react-dom@18.2.0)(react@18.2.0)(reflect-metadata@0.1.14) '@antv/xflow-hook': 1.1.52 - antd: 5.13.1(react-dom@18.2.0)(react@18.2.0) + antd: 5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0) lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -7776,6 +7794,41 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@types/react': 18.2.78 + '@types/react-dom': 18.2.7 + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.2.78)(react@18.2.0) + dev: false + /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} peerDependencies: @@ -7870,6 +7923,28 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.78)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.78 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.78)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -10242,6 +10317,12 @@ packages: /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + /@types/jsonexport@3.0.5: + resolution: {integrity: sha512-+zDVaDqNguePOU11YNEsuf/ZRS1ciHeKVYGjfvDFmgG6aCy/s4W4Wy1MiXJIbiyMJLuakGLENCndvmGZpVTV1w==} + dependencies: + '@types/node': 18.17.12 + dev: false + /@types/jsonfile@6.1.3: resolution: {integrity: sha512-/yqTk2SZ1wIezK0hiRZD7RuSf4B3whFxFamB1kGStv+8zlWScTMcHanzfc0XKWs5vA1TkHeckBlOyM8jxU8nHA==} dependencies: @@ -11169,7 +11250,7 @@ packages: - moment dev: false - /antd@5.13.1(react-dom@18.2.0)(react@18.2.0): + /antd@5.13.1(date-fns@3.6.0)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-/qAPsr6UyJPSFZQD9G7kW98GelH2Bajli+1q7CRW4IinYQ0R0UVJckFX11emByhiU4Jd4WNH/hOO+fZtp0eVDA==} peerDependencies: react: '>=16.9.0' @@ -11203,7 +11284,7 @@ packages: rc-motion: 2.9.0(react-dom@18.2.0)(react@18.2.0) rc-notification: 5.3.0(react-dom@18.2.0)(react@18.2.0) rc-pagination: 4.0.4(react-dom@18.2.0)(react@18.2.0) - rc-picker: 3.14.6(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0) + rc-picker: 3.14.6(date-fns@3.6.0)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0) rc-progress: 3.5.1(react-dom@18.2.0)(react@18.2.0) rc-rate: 2.12.0(react-dom@18.2.0)(react@18.2.0) rc-resize-observer: 1.4.0(react-dom@18.2.0)(react@18.2.0) @@ -13675,6 +13756,10 @@ packages: dependencies: '@babel/runtime': 7.24.0 + /date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dev: false + /dayjs@1.11.10: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} dev: false @@ -17455,6 +17540,11 @@ packages: resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} dev: true + /jsonexport@3.2.0: + resolution: {integrity: sha512-GbO9ugb0YTZatPd/hqCGR0FSwbr82H6OzG04yzdrG7XOe4QZ0jhQ+kOsB29zqkzoYJLmLxbbrFiuwbQu891XnQ==} + hasBin: true + dev: false + /jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} optionalDependencies: @@ -20809,7 +20899,7 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.33 - ts-node: 10.9.1(@types/node@18.17.12)(typescript@4.7.4) + ts-node: 10.9.1(@types/node@18.17.12)(typescript@5.4.5) yaml: 2.3.2 dev: true @@ -22496,6 +22586,36 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /rc-picker@3.14.6(date-fns@3.6.0)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: 1.11.10 + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@rc-component/trigger': 1.18.2(react-dom@18.2.0)(react@18.2.0) + classnames: 2.5.1 + date-fns: 3.6.0 + dayjs: 1.11.10 + rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /rc-picker@3.14.6(dayjs@1.11.10)(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==} engines: {node: '>=8.x'} @@ -22525,35 +22645,6 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false - /rc-picker@3.14.6(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-AdKKW0AqMwZsKvIpwUWDUnpuGKZVrbxVTZTNjcO+pViGkjC1EBcjMgxVe8tomOEaIHJL5Gd13vS8Rr3zzxWmag==} - engines: {node: '>=8.x'} - peerDependencies: - date-fns: '>= 2.x' - dayjs: 1.11.10 - luxon: '>= 3.x' - moment: '>= 2.x' - react: '>=16.9.0' - react-dom: '>=16.9.0' - peerDependenciesMeta: - date-fns: - optional: true - dayjs: - optional: true - luxon: - optional: true - moment: - optional: true - dependencies: - '@babel/runtime': 7.24.0 - '@rc-component/trigger': 1.18.2(react-dom@18.2.0)(react@18.2.0) - classnames: 2.5.1 - dayjs: 1.11.10 - rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /rc-progress@3.5.1(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-V6Amx6SbLRwPin/oD+k1vbPrO8+9Qf8zW1T8A7o83HdNafEVvAxPV5YsgtKFP+Ud5HghLj33zKOcEHrcrUGkfw==} peerDependencies: @@ -23138,6 +23229,16 @@ packages: react: 18.2.0 dev: false + /react-day-picker@8.10.1(date-fns@3.6.0)(react@18.2.0): + resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + date-fns: 3.6.0 + react: 18.2.0 + dev: false + /react-dev-utils@12.0.1(typescript@4.7.4)(webpack@5.89.0): resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} @@ -23488,6 +23589,7 @@ packages: /react-remove-scroll-bar@2.3.5(@types/react@18.2.78)(react@18.2.0): resolution: {integrity: sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==} engines: {node: '>=10'} + deprecated: please update to the following version as this contains a bug (https://github.com/theKashey/react-remove-scroll-bar/issues/57) peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -26317,7 +26419,6 @@ packages: typescript: 5.4.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - dev: false /ts-pattern@4.3.0: resolution: {integrity: sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==} diff --git a/src/client/components/survey/SurveyDownloadBtn.tsx b/src/client/components/survey/SurveyDownloadBtn.tsx new file mode 100644 index 0000000..dc5b9f0 --- /dev/null +++ b/src/client/components/survey/SurveyDownloadBtn.tsx @@ -0,0 +1,199 @@ +import React, { useRef, useState } from 'react'; +import { + DialogHeader, + DialogFooter, + Dialog, + DialogTrigger, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useTranslation } from '@i18next-toolkit/react'; +import { LuCalendar } from 'react-icons/lu'; +import { cn } from '@/utils/style'; +import dayjs from 'dayjs'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Calendar } from '../ui/calendar'; +import { DateRange } from 'react-day-picker'; +import { AppRouterOutput, trpc } from '@/api/trpc'; +import { useCurrentWorkspaceId } from '@/store/user'; +import { useEvent } from '@/hooks/useEvent'; +import { clamp, pick } from 'lodash-es'; +import { Progress } from '../ui/progress'; +import jsonExport from 'jsonexport/dist'; +import { downloadCSV } from '@/utils/dom'; +import { message } from 'antd'; + +interface SurveyDownloadBtnProps { + surveyId: string; +} +export const SurveyDownloadBtn: React.FC = React.memo( + (props) => { + const { surveyId } = props; + const workspaceId = useCurrentWorkspaceId(); + const [date, setDate] = useState({ + from: dayjs().subtract(1, 'months').toDate(), + to: dayjs().toDate(), + }); + const { t } = useTranslation(); + + const { data: info } = trpc.survey.get.useQuery({ + workspaceId, + surveyId, + }); + const { data: count = 1 } = trpc.survey.count.useQuery({ + workspaceId, + surveyId, + }); + const [downloadProgress, setDownloadProgress] = useState( + null + ); + + const trpcUtils = trpc.useUtils(); + + const handleStart = useEvent(async () => { + try { + const limit = 1000; + let cursor: string | undefined = undefined; + const downloadResultList: AppRouterOutput['survey']['resultList']['items'][number][] = + []; + setDownloadProgress(0); + const startAt = date?.from + ? dayjs(date.from).startOf('day').valueOf() + : undefined; + const endAt = date?.to + ? dayjs(date.to).endOf('day').valueOf() + : undefined; + + while (true) { + const { items, nextCursor } = await trpcUtils.survey.resultList.fetch( + { + workspaceId, + surveyId, + limit: 1000, + cursor, + startAt, + endAt, + } + ); + + downloadResultList.push(...items); + setDownloadProgress(clamp(downloadResultList.length / count, 0, 1)); + + cursor = nextCursor; + if (items.length < limit) { + // no more + break; + } + } + + // download with csv file + const fields = info?.payload.items ?? []; + const csv = await jsonExport( + downloadResultList.map((item) => { + const map = fields.reduce( + (prev, curr) => ({ + ...prev, + [curr.label]: item.payload[curr.name], + }), + {} as Record + ); + + return { + id: item.id, + sessionId: item.sessionId, + ...map, + ...pick(item, [ + 'language', + 'browser', + 'os', + 'country', + 'subdivision1', + 'subdivision2', + 'city', + ]), + }; + }) + ); + let filename = info?.name ?? surveyId; + if (date && startAt && endAt) { + filename += `-${dayjs(startAt).format('YYYY-MM-DD')}-${dayjs(endAt).format('YYYY-MM-DD')}`; + } + downloadCSV(csv, filename); + } catch (err) { + message.error(String(err)); + } finally { + setDownloadProgress(null); + } + }); + + return ( + + + + + + + {t('Download')} + + {t('Download survey data with csv for further use.')} + + +
+ + + + + + + + +
+ +
+ {typeof downloadProgress === 'number' && ( + + )} +
+ +
+
+
+ ); + } +); +SurveyDownloadBtn.displayName = 'SurveyDownloadBtn'; diff --git a/src/client/components/ui/calendar.tsx b/src/client/components/ui/calendar.tsx new file mode 100644 index 0000000..e3dc8c7 --- /dev/null +++ b/src/client/components/ui/calendar.tsx @@ -0,0 +1,70 @@ +import * as React from "react" +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/utils/style" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/src/client/components/ui/popover.tsx b/src/client/components/ui/popover.tsx new file mode 100644 index 0000000..b171843 --- /dev/null +++ b/src/client/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/utils/style" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/client/components/ui/progress.tsx b/src/client/components/ui/progress.tsx new file mode 100644 index 0000000..192c74f --- /dev/null +++ b/src/client/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/utils/style" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/client/package.json b/src/client/package.json index 5adcfaa..1ed1cc5 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -35,6 +35,8 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", @@ -50,6 +52,7 @@ "@tianji/shared": "workspace:^", "@trpc/client": "^10.45.0", "@trpc/react-query": "^10.45.0", + "@types/jsonexport": "^3.0.5", "ahooks": "^3.7.10", "antd": "^5.13.1", "array-move": "^3.0.1", @@ -60,10 +63,12 @@ "cmdk": "^1.0.0", "colord": "^2.9.3", "copy-to-clipboard": "^3.3.3", + "date-fns": "^3.6.0", "dayjs": "^1.11.9", "eventemitter-strict": "^1.0.1", "filesize": "^10.0.12", "fuse.js": "^7.0.0", + "jsonexport": "^3.2.0", "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "lucide-react": "^0.358.0", @@ -71,6 +76,7 @@ "next-themes": "^0.2.1", "pretty-ms": "^9.0.0", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-easy-sort": "^1.5.3", "react-grid-layout": "1.4.2", diff --git a/src/client/routes/survey/$surveyId/index.tsx b/src/client/routes/survey/$surveyId/index.tsx index 5a7a6b6..2c7b5da 100644 --- a/src/client/routes/survey/$surveyId/index.tsx +++ b/src/client/routes/survey/$surveyId/index.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTable, createColumnHelper } from '@/components/DataTable'; import { useMemo } from 'react'; +import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn'; type SurveyResultItem = AppRouterOutput['survey']['resultList']['items'][number]; @@ -126,7 +127,12 @@ function PageComponent() { {t('Count')} - {count} + +
{count}
+
+ +
+
diff --git a/src/client/utils/dom.ts b/src/client/utils/dom.ts index 3519e3a..7c78fba 100644 --- a/src/client/utils/dom.ts +++ b/src/client/utils/dom.ts @@ -13,3 +13,20 @@ export function preventDefault(e: Event) { } export const rootEl = document.getElementById('root'); + +export function downloadCSV(csv: string, filename: string): void { + const fakeLink = document.createElement('a'); + fakeLink.style.display = 'none'; + document.body.appendChild(fakeLink); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + // @ts-ignore + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + // Manage IE11+ & Edge + // @ts-ignore + window.navigator.msSaveOrOpenBlob(blob, `${filename}.csv`); + } else { + fakeLink.setAttribute('href', URL.createObjectURL(blob)); + fakeLink.setAttribute('download', `${filename}.csv`); + fakeLink.click(); + } +} diff --git a/src/server/trpc/routers/survey.ts b/src/server/trpc/routers/survey.ts index f60e0bf..9a8ec12 100644 --- a/src/server/trpc/routers/survey.ts +++ b/src/server/trpc/routers/survey.ts @@ -15,6 +15,7 @@ import { getRequestInfo } from '../../utils/detect'; import { SurveyPayloadSchema } from '../../prisma/zod/schemas'; import { buildCursorResponseSchema } from '../../utils/schema'; import { fetchDataByCursor } from '../../utils/prisma'; +import { Prisma } from '@prisma/client'; export const surveyRouter = router({ all: workspaceProcedure @@ -259,8 +260,10 @@ export const surveyRouter = router({ .input( z.object({ surveyId: z.string(), - limit: z.number().min(1).max(100).default(50), + limit: z.number().min(1).max(1000).default(50), cursor: z.string().optional(), + startAt: z.number().optional(), + endAt: z.number().optional(), }) ) .output(buildCursorResponseSchema(SurveyResultModelSchema)) @@ -268,12 +271,21 @@ export const surveyRouter = router({ const limit = input.limit; const { cursor, surveyId } = input; + const where: Prisma.SurveyResultWhereInput = { + surveyId, + }; + + if (input.startAt && input.endAt) { + where.createdAt = { + gte: new Date(input.startAt), + lte: new Date(input.endAt), + }; + } + const { items, nextCursor } = await fetchDataByCursor( prisma.surveyResult, { - where: { - surveyId, - }, + where, limit, cursor, } diff --git a/src/server/utils/prisma.ts b/src/server/utils/prisma.ts index 3279ad5..a834b7f 100644 --- a/src/server/utils/prisma.ts +++ b/src/server/utils/prisma.ts @@ -220,6 +220,12 @@ type ExtractFindManyReturnType = T extends ( ? R : never; +type ExtractFindManyWhereType< + T extends { + findMany: (args?: any) => Prisma.PrismaPromise; + }, +> = NonNullable[0]>['where']; + /** * @example * const { items, nextCursor } = await fetchDataByCursor( @@ -241,7 +247,8 @@ export async function fetchDataByCursor< >( fetchModel: Model, options: { - where: Record; + // where: Record; + where: ExtractFindManyWhereType; limit: number; cursor: CursorType; cursorName?: string;