From 7f385bf1756268e60de13d03f57d5ad8d31e351f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Sun, 9 Nov 2025 22:16:07 -0300 Subject: [PATCH] add download --- .../app/components/data-table/data-table.tsx | 11 +- .../app/components/range-calendar-filter.tsx | 2 +- .../_.$orgid.enrollments._index/columns.tsx | 4 +- .../_.$orgid.enrollments._index/data.tsx | 4 +- .../_.$orgid.enrollments._index/route.tsx | 274 ++++++++++++------ apps/admin.saladeaula.digital/package.json | 3 +- id.saladeaula.digital/template.yaml | 47 +-- package-lock.json | 26 +- 8 files changed, 224 insertions(+), 147 deletions(-) diff --git a/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx index a7d593f..14a63f6 100644 --- a/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx +++ b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx @@ -10,7 +10,7 @@ import { type Table, type VisibilityState } from '@tanstack/react-table' -import { createContext, useState, type ReactNode } from 'react' +import { createContext, useEffect, useState, type ReactNode } from 'react' import { useSearchParams } from 'react-router' import { Card, CardContent } from '@repo/ui/components/ui/card' @@ -28,6 +28,7 @@ interface DataTableProps { children?: ReactNode columns: ColumnDef[] data: TData[] + onRowSelectionChange?: (rowSelection: TData[]) => void pageIndex: number sort: SortingState pageSize: number @@ -45,6 +46,7 @@ export function DataTable({ pageIndex, pageSize, rowCount, + onRowSelectionChange, hiddenColumn = [] }: DataTableProps) { const [searchParams, setSearchParams] = useSearchParams() @@ -88,8 +90,6 @@ export function DataTable({ }) } - // table.getSelectedRowModel().flatRows.map((row) => row.original) - const table = useReactTable({ data, columns, @@ -114,6 +114,11 @@ export function DataTable({ onPaginationChange: setPagination }) + useEffect(() => { + const selected = table.getSelectedRowModel().flatRows.map((r) => r.original) + onRowSelectionChange?.(selected) + }, [rowSelection, table]) + return (
diff --git a/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx b/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx index 24dbdc1..aa80863 100644 --- a/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx +++ b/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx @@ -68,7 +68,7 @@ export function RangeCalendarFilter({
diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index ff21685..a31114c 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -130,9 +130,9 @@ export const columns: ColumnDef[] = [ { accessorKey: 'created_at', header: ({ column }) => ( - + ), - meta: { title: 'Matriculado em' }, + meta: { title: 'Cadastrado em' }, enableSorting: true, enableHiding: true, cell: cellDate diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx index e35dd19..1e000a8 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx @@ -40,13 +40,13 @@ export const statuses: Record< export const labels: Record = { PENDING: 'Não iniciado', IN_PROGRESS: 'Em andamento', - COMPLETED: 'Aprovado', + COMPLETED: 'Concluído', FAILED: 'Reprovado', CANCELED: 'Cancelado' } export const sortings: Record = { - created_at: 'Matriculado em', + created_at: 'Cadastrado em', started_at: 'Iniciado em', completed_at: 'Concluído em', failed_at: 'Reprovado em', diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 74822b3..9bd676b 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -1,9 +1,21 @@ import type { Route } from './+types' -import { CalendarIcon, PlusCircleIcon, PlusIcon } from 'lucide-react' +import { flatten } from 'flat' +import { + CalendarIcon, + ChevronDownIcon, + DownloadIcon, + FileSpreadsheetIcon, + FileTextIcon, + PlusCircleIcon, + PlusIcon, + TagIcon +} from 'lucide-react' import { MeiliSearchFilterBuilder } from 'meilisearch-helper' -import { Suspense } from 'react' +import { Suspense, useState } from 'react' import { Await, Link, useSearchParams } from 'react-router' +import type { BookType } from 'xlsx' +import * as XLSX from 'xlsx' import { DataTable, DataTableViewOptions } from '@/components/data-table' import { RangeCalendarFilter } from '@/components/range-calendar-filter' @@ -13,6 +25,13 @@ import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { SearchForm } from '@repo/ui/components/search-form' import { Skeleton } from '@repo/ui/components/skeleton' import { Button } from '@repo/ui/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger +} from '@repo/ui/components/ui/dropdown-menu' import { Kbd } from '@repo/ui/components/ui/kbd' import { columns, type Enrollment } from './columns' import { sortings, statuses } from './data' @@ -64,6 +83,7 @@ const formatted = new Intl.DateTimeFormat('en-CA', { export default function Route({ loaderData: { data } }) { const [searchParams, setSearchParams] = useSearchParams() + const [rowSelection, setRowSelection] = useState([]) const status = searchParams.get('status') const rangeParams = useRangeParams() @@ -87,6 +107,7 @@ export default function Route({ loaderData: { data } }) { data={hits as Enrollment[]} pageIndex={page - 1} pageSize={hitsPerPage} + onRowSelectionChange={setRowSelection} rowCount={totalHits} hiddenColumn={[ 'completed_at', @@ -95,97 +116,112 @@ export default function Route({ loaderData: { data } }) { 'canceled_at' ]} > -
-
- - Digite / para - pesquisar - - } - onChange={(value) => - setSearchParams((searchParams) => { - searchParams.set('q', String(value)) - searchParams.delete('p') - return searchParams - }) - } - /> -
- -
-
- { - setSearchParams((searchParams) => { - searchParams.delete('status') - searchParams.delete('p') - - if (statuses.length) { - searchParams.set('status', statuses.join(',')) - } - - return searchParams - }) - }} - options={Object.entries(statuses).map(([key, value]) => ({ - value: key, - ...value - }))} - /> - - ({ - value, - label - }) - )} - onChange={(props) => { - setSearchParams((searchParams) => { - if (!props) { - searchParams.delete('from') - searchParams.delete('to') +
+ {rowSelection.length ? ( + <> +
+ + +
+ + ) : ( + <> +
+ + Digite /{' '} + para pesquisar + + } + onChange={(value) => + setSearchParams((searchParams) => { + searchParams.set('q', String(value)) + searchParams.delete('p') return searchParams - } + }) + } + /> +
- const { rangeField, dateRange } = props +
+
+ { + setSearchParams((searchParams) => { + searchParams.delete('status') + searchParams.delete('p') - searchParams.set( - 'from', - `${rangeField}:${formatted.format(dateRange?.from)}` - ) - searchParams.set( - 'to', - formatted.format(dateRange?.to) - ) + if (statuses.length) { + searchParams.set('status', statuses.join(',')) + } - return searchParams - }) - }} - /> -
+ return searchParams + }) + }} + options={Object.entries(statuses).map( + ([key, value]) => ({ + value: key, + ...value + }) + )} + /> -
- + ({ + value, + label + }) + )} + onChange={(props) => { + setSearchParams((searchParams) => { + if (!props) { + searchParams.delete('from') + searchParams.delete('to') + return searchParams + } - -
-
+ const { rangeField, dateRange } = props + + searchParams.set( + 'from', + `${rangeField}:${formatted.format(dateRange?.from)}` + ) + searchParams.set( + 'to', + formatted.format(dateRange?.to) + ) + + return searchParams + }) + }} + /> +
+ +
+ + + +
+
+ + )}
) @@ -213,3 +249,67 @@ function useRangeParams() { } } } + +export function DropdownMenuExport({ + rowSelection = [] +}: { + rowSelection: object[] +}) { + const headers = { + id: 'ID', + 'user.name': 'Nome', + 'user.email': 'Email', + 'user.cpf': 'CPF', + 'course.name': 'Curso', + status: 'Status', + progress: 'Progresso', + created_at: 'Cadastrado em', + started_at: 'Iniciado em', + completed_at: 'Concluído em', + failed_at: 'Reprovado em', + canceled_at: 'Cancelado em' + } + + const handleExport = (bookType: BookType) => () => { + if (!rowSelection.length) { + return + } + + const header = Object.keys(headers) + const data = rowSelection.map((data) => { + const obj: Record = flatten(data) + return Object.fromEntries(header.map((k) => [k, obj?.[k]])) + }) + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.json_to_sheet(data, { header }) + + XLSX.utils.sheet_add_aoa(worksheet, [Object.values(headers)], { + origin: 'A1' + }) + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1') + XLSX.writeFile(workbook, `${formatted.format(new Date())}.${bookType}`, { + bookType, + compression: true + }) + } + + return ( + + + + + + + + Microsoft Excel (.xlsx) + + + CSV (.csv) + + + + + ) +} diff --git a/apps/admin.saladeaula.digital/package.json b/apps/admin.saladeaula.digital/package.json index c562349..7fbe386 100644 --- a/apps/admin.saladeaula.digital/package.json +++ b/apps/admin.saladeaula.digital/package.json @@ -18,7 +18,7 @@ "@tanstack/react-table": "^8.21.3", "cookie": "^1.0.2", "date-fns": "^4.1.0", - "file-saver": "^2.0.5", + "flat": "^6.0.1", "fuse.js": "^7.1.0", "http-status-codes": "^2.3.0", "isbot": "^5.1.31", @@ -34,6 +34,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "^1.13.18", "@tailwindcss/vite": "^4.1.16", + "@types/file-saver": "^2.0.7", "@types/luxon": "^3.7.1", "@types/node": "^24.9.2", "@types/react": "^19.2.2", diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index 172868e..19c991f 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -36,7 +36,7 @@ Resources: Type: AWS::Serverless::HttpApi Properties: CorsConfiguration: - AllowOrigins: ["*"] + AllowOrigins: ['*'] AllowMethods: [GET, POST, OPTIONS] AllowHeaders: [Content-Type, X-Requested-With, Authorization] @@ -99,55 +99,12 @@ Resources: Method: GET ApiId: !Ref HttpApi - OIDCDistribution: - Type: AWS::CloudFront::Distribution - Properties: - DistributionConfig: - Enabled: true - Origins: - - Id: OidcApiOrigin - DomainName: !Sub "${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" - CustomOriginConfig: - OriginProtocolPolicy: https-only - DefaultCacheBehavior: - TargetOriginId: OidcApiOrigin - ViewerProtocolPolicy: redirect-to-https - AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE] - CachedMethods: [GET, HEAD] - ForwardedValues: - QueryString: true - Headers: - - Origin - - Access-Control-Request-Method - - Access-Control-Request-Headers - Cookies: - Forward: all - DefaultTTL: 0 - MinTTL: 0 - MaxTTL: 0 - CacheBehaviors: - - PathPattern: "/.well-known/*" - TargetOriginId: OidcApiOrigin - ViewerProtocolPolicy: redirect-to-https - AllowedMethods: [GET, HEAD, OPTIONS] - CachedMethods: [GET, HEAD, OPTIONS] - ForwardedValues: - QueryString: false - Headers: - - Origin - DefaultTTL: 3600 # 1 hour - MinTTL: 300 # 5 minutes - MaxTTL: 86400 # 1 day - Outputs: HttpApiUrl: Description: URL of your API endpoint Value: - Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}' HttpApiId: Description: Api ID of HttpApi Value: Ref: HttpApi - OIDCDistributionDomain: - Description: Domain of CloudFront Distribution domain for OIDC endpoints - Value: !GetAtt OIDCDistribution.DomainName diff --git a/package-lock.json b/package-lock.json index 6e546d7..9d99166 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@tanstack/react-table": "^8.21.3", "cookie": "^1.0.2", "date-fns": "^4.1.0", - "file-saver": "^2.0.5", + "flat": "^6.0.1", "fuse.js": "^7.1.0", "http-status-codes": "^2.3.0", "isbot": "^5.1.31", @@ -47,6 +47,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "^1.13.18", "@tailwindcss/vite": "^4.1.16", + "@types/file-saver": "^2.0.7", "@types/luxon": "^3.7.1", "@types/node": "^24.9.2", "@types/react": "^19.2.2", @@ -4680,6 +4681,13 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-cookie": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", @@ -5324,11 +5332,17 @@ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", "license": "MIT" }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", - "license": "MIT" + "node_modules/flat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-6.0.1.tgz", + "integrity": "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + }, + "engines": { + "node": ">=18" + } }, "node_modules/foreground-child": { "version": "3.3.1",