diff --git a/charmClient/apis/proposalsApi.ts b/charmClient/apis/proposalsApi.ts index 7c167c5c86..2b682f637c 100644 --- a/charmClient/apis/proposalsApi.ts +++ b/charmClient/apis/proposalsApi.ts @@ -40,4 +40,12 @@ export class ProposalsApi { exportProposalsReviewers({ spaceId }: { spaceId: string }) { return http.GET(`/api/spaces/${spaceId}/proposals/reviewers/export`); } + + exportUserProposals({ spaceId }: { spaceId: string }) { + return http.GET(`/api/spaces/${spaceId}/proposals/my-work/export`); + } + + exportFilteredProposals({ spaceId }: { spaceId: string }) { + return http.GET(`/api/spaces/${spaceId}/proposals/export`); + } } diff --git a/charmClient/hooks/proposals.ts b/charmClient/hooks/proposals.ts index 6bf53cbd0c..dd167bea43 100644 --- a/charmClient/hooks/proposals.ts +++ b/charmClient/hooks/proposals.ts @@ -196,7 +196,7 @@ export function useResetEvaluationAppealReview({ evaluationId }: { evaluationId: } export function useGetUserProposals(params: { spaceId: MaybeString }) { - return useGET(params.spaceId ? `/api/spaces/${params.spaceId}/proposals/work` : null); + return useGET(params.spaceId ? `/api/spaces/${params.spaceId}/proposals/my-work` : null); } export function useGetProposalsReviewers(params: { spaceId: MaybeString }) { diff --git a/components/common/DatabaseEditor/components/viewSidebar/viewSidebar.tsx b/components/common/DatabaseEditor/components/viewSidebar/viewSidebar.tsx index fbfc53d820..6fde2b4692 100644 --- a/components/common/DatabaseEditor/components/viewSidebar/viewSidebar.tsx +++ b/components/common/DatabaseEditor/components/viewSidebar/viewSidebar.tsx @@ -1,8 +1,11 @@ import type { PageMeta } from '@charmverse/core/pages'; import { ClickAwayListener, Collapse } from '@mui/material'; import type { Dispatch, SetStateAction } from 'react'; -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import charmClient from 'charmClient'; +import { Button } from 'components/common/Button'; +import { useCurrentSpace } from 'hooks/useCurrentSpace'; import type { Board, IPropertyTemplate } from 'lib/databases/board'; import type { BoardView, IViewType } from 'lib/databases/boardView'; import type { Card } from 'lib/databases/card'; @@ -38,6 +41,7 @@ interface Props { selectedPropertyId: string | null; setSelectedPropertyId: Dispatch>; sidebarView?: SidebarView; + isProposal?: boolean; isReward?: boolean; } @@ -50,6 +54,24 @@ function ViewSidebar(props: Props) { function goToSidebarHome() { setSidebarView('view-options'); } + const { space: currentSpace } = useCurrentSpace(); + + const exportToCSV = useCallback(() => { + if (currentSpace) { + charmClient.proposals + .exportFilteredProposals({ + spaceId: currentSpace.id + }) + .then((csvContent) => { + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'Proposals.csv'; + a.click(); + }); + } + }, [currentSpace?.id]); useEffect(() => { if (!props.isOpen) { @@ -148,6 +170,11 @@ function ViewSidebar(props: Props) { isReward={props.isReward} /> )} + {props.isProposal && ( + + )} diff --git a/components/common/DatabaseEditor/components/viewSidebar/viewSidebarSelect.tsx b/components/common/DatabaseEditor/components/viewSidebar/viewSidebarSelect.tsx index a0a2d54e04..c4e6b4537e 100644 --- a/components/common/DatabaseEditor/components/viewSidebar/viewSidebarSelect.tsx +++ b/components/common/DatabaseEditor/components/viewSidebar/viewSidebarSelect.tsx @@ -1,4 +1,3 @@ -import { Apps } from '@mui/icons-material'; import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted'; import GroupIcon from '@mui/icons-material/GroupWorkOutlined'; import ArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; @@ -6,11 +5,9 @@ import PreviewIcon from '@mui/icons-material/Preview'; import TaskOutlinedIcon from '@mui/icons-material/TaskOutlined'; import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material'; import { capitalize } from '@root/lib/utils/strings'; -import { usePopupState } from 'material-ui-popup-state/hooks'; import { FcGoogle } from 'react-icons/fc'; import { RiFolder2Line } from 'react-icons/ri'; -import { Button } from 'components/common/Button'; import { PageIcon } from 'components/common/PageIcon'; import { usePages } from 'hooks/usePages'; import type { Board, DataSourceType, IPropertyTemplate } from 'lib/databases/board'; @@ -101,7 +98,6 @@ export function ViewSidebarSelect({ hidePropertiesRow }: Props) { const { pages } = usePages(); - const withGroupBy = view?.fields.viewType.match(/board/) || view?.fields.viewType === 'table'; const currentGroup = board?.fields.cardProperties.find((prop) => prop.id === groupByProperty?.id)?.name; const currentLayout = view?.fields.viewType; diff --git a/components/common/DatabaseEditor/store/cards.ts b/components/common/DatabaseEditor/store/cards.ts index c4507fefc5..81167ce2e3 100644 --- a/components/common/DatabaseEditor/store/cards.ts +++ b/components/common/DatabaseEditor/store/cards.ts @@ -138,7 +138,7 @@ export function sortCards( cards: Card[], board: Pick, activeView: BoardView, - members: Record, + members: Record>, cardTitles: Record, localSort?: ISortOption[] | null ): Card[] { diff --git a/components/proposals/ProposalsPage.tsx b/components/proposals/ProposalsPage.tsx index 07042902a2..c21b7c7dbe 100644 --- a/components/proposals/ProposalsPage.tsx +++ b/components/proposals/ProposalsPage.tsx @@ -9,6 +9,7 @@ import { debounce } from 'lodash'; import { useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import charmClient from 'charmClient'; import { useTrashPages } from 'charmClient/hooks/pages'; import { Button } from 'components/common/Button'; import { CharmEditor } from 'components/common/CharmEditor'; @@ -368,6 +369,7 @@ export function ProposalsPage({ title }: { title: string }) { closeSidebar={() => { setShowSidebar(false); }} + isProposal hideLayoutOptions hideSourceOptions hideGroupOptions diff --git a/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx b/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx index 284c83ad8f..e5a72ec8ef 100644 --- a/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx +++ b/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx @@ -1,30 +1,62 @@ import { ThumbUpOutlined as ApprovedIcon, HighlightOff as RejectedIcon } from '@mui/icons-material'; import ProposalIcon from '@mui/icons-material/TaskOutlined'; -import { Box, Button, Card, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; +import { Box, Card, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; import { Stack } from '@mui/system'; import type { CustomColumn, UserProposal } from '@root/lib/proposals/getUserProposals'; import { useRouter } from 'next/router'; +import { useCallback } from 'react'; +import charmClient from 'charmClient'; +import { Button } from 'components/common/Button'; import Link from 'components/common/Link'; import { evaluationIcons } from 'components/settings/proposals/constants'; +import { useCurrentSpace } from 'hooks/useCurrentSpace'; import { CustomColumnTableCells } from './CustomColumnTableCells'; import { OpenButton, StyledTable, StyledTableRow } from './ProposalsTable'; export function ActionableProposalsTable({ proposals, - customColumns + customColumns, + totalProposals }: { proposals: UserProposal[]; customColumns: CustomColumn[]; + totalProposals: number; }) { + const { space } = useCurrentSpace(); + const router = useRouter(); + const exportToCSV = useCallback(() => { + if (space) { + charmClient.proposals.exportUserProposals({ spaceId: space.id }).then((csvContent) => { + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'My Proposals.csv'; + a.click(); + }); + } + }, [!!space?.id]); + return ( - - Ready for review - + + + Ready for review + + + {proposals.length ? ( @@ -179,7 +211,7 @@ export function ActionableProposalsTable({ color='primary' size='small' sx={{ mr: 2, width: 75 }} - onClick={(e) => { + onClick={(e: MouseEvent) => { e.stopPropagation(); router.push(`/${router.query.domain}/${proposal.path}`); }} diff --git a/components/proposals/components/UserProposalsTables/UserProposalsTables.tsx b/components/proposals/components/UserProposalsTables/UserProposalsTables.tsx index a23aa932c3..d7b8088127 100644 --- a/components/proposals/components/UserProposalsTables/UserProposalsTables.tsx +++ b/components/proposals/components/UserProposalsTables/UserProposalsTables.tsx @@ -23,7 +23,11 @@ export function UserProposalsTables() { ) : ( - + {proposals.authored.length ? ( column.title) ?? []) + ] + ]; + + const allProposals = [...userProposals.actionable, ...userProposals.authored, ...userProposals.review_completed]; + + allProposals.forEach((proposal) => { + const row = [ + proposal.title || 'Untitled', + proposal.currentEvaluation?.result + ? proposal.currentEvaluation.result === 'pass' + ? 'Passed' + : 'Declined' + : 'In progress', + proposal.status === 'draft' ? 'Draft' : proposal.currentEvaluation?.title || 'Evaluation', + proposal.userReviewResult || '-', + proposal.totalPassedReviewResults?.toString() || '-', + proposal.totalFailedReviewResults?.toString() || '-', + `${baseUrl}/${space.domain}/${proposal.path}`, + ...(userProposals.customColumns?.map((column) => { + const customValue = proposal.customColumns?.find((c) => c.formFieldId === column.formFieldId)?.value; + if (column.type === 'select' || column.type === 'multiselect') { + return column.options.find((opt) => opt.id === customValue)?.name || '-'; + } + return (customValue as string) || '-'; + }) ?? []) + ]; + rows.push(row); + }); + + rows.forEach((row) => { + const encodedRow = row.join('\t'); + csvContent += `${encodedRow}\r\n`; + }); + + return csvContent; +} diff --git a/lib/proposals/exportProposals.ts b/lib/proposals/exportProposals.ts new file mode 100644 index 0000000000..56c64ed9a3 --- /dev/null +++ b/lib/proposals/exportProposals.ts @@ -0,0 +1,163 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { objectUtils } from '@charmverse/core/utilities'; +import type { BoardView } from '@root/lib/databases/boardView'; +import type { Card } from '@root/lib/databases/card'; +import type { ProposalBoardBlock } from '@root/lib/proposals/blocks/interfaces'; +import { formatDate, formatDateTime } from '@root/lib/utils/dates'; +import { stringify } from 'csv-stringify/sync'; + +import { OctoUtils } from 'components/common/DatabaseEditor/octoUtils'; +import { sortCards } from 'components/common/DatabaseEditor/store/cards'; +import { blockToFBBlock } from 'components/common/DatabaseEditor/utils/blockUtils'; +import { getDefaultBoard, getDefaultTableView } from 'components/proposals/components/ProposalsBoard/utils/boardData'; +import { mapProposalToCard } from 'components/proposals/ProposalPage/components/ProposalProperties/hooks/useProposalsBoardAdapter'; +import { CardFilter } from 'lib/databases/cardFilter'; +import { Constants } from 'lib/databases/constants'; +import { PROPOSAL_STEP_LABELS } from 'lib/databases/proposalDbProperties'; +import { permissionsApiClient } from 'lib/permissions/api/client'; +import { CREATED_AT_ID, PROPOSAL_EVALUATION_TYPE_ID } from 'lib/proposals/blocks/constants'; +import { getProposals } from 'lib/proposals/getProposals'; + +export async function exportProposals({ spaceId, userId }: { spaceId: string; userId: string }) { + const space = await prisma.space.findUniqueOrThrow({ where: { id: spaceId }, select: { domain: true } }); + + const [proposalViewBlock, proposalBoardBlock] = await Promise.all([ + prisma.proposalBlock.findUnique({ + where: { + id_spaceId: { + id: '__defaultView', + spaceId + } + } + }), + prisma.proposalBlock.findFirst({ + where: { + spaceId, + type: '__defaultBoard' + } + }) + ]); + + const ids = await permissionsApiClient.proposals.getAccessibleProposalIds({ + userId, + spaceId + }); + + const [proposals, spaceMembers] = await Promise.all([ + getProposals({ ids, spaceId, userId }), + prisma.user.findMany({ + where: { + spaceRoles: { + some: { + spaceId + } + } + }, + select: { + id: true, + username: true + } + }) + ]); + + const membersRecord = spaceMembers.reduce>((acc, user) => { + acc[user.id] = { username: user.username }; + return acc; + }, {}); + + const evaluationStepTitles = new Set(); + proposals.forEach((p) => { + p.evaluations.forEach((e) => { + evaluationStepTitles.add(e.title); + }); + }); + + const board = getDefaultBoard({ + storedBoard: proposalBoardBlock as ProposalBoardBlock, + evaluationStepTitles: Array.from(evaluationStepTitles) + }); + + let viewBlock = proposalViewBlock ? (blockToFBBlock(proposalViewBlock as ProposalBoardBlock) as BoardView) : null; + + if (!viewBlock) { + viewBlock = getDefaultTableView({ board }); + viewBlock.fields.sortOptions = [{ propertyId: CREATED_AT_ID, reversed: true }]; + } + + let cards = proposals.map((p) => mapProposalToCard({ proposal: p, spaceId })); + + if (viewBlock.fields.filter) { + const filteredCardsIds = CardFilter.applyFilterGroup( + viewBlock.fields.filter, + [ + ...board.fields.cardProperties, + { + id: PROPOSAL_EVALUATION_TYPE_ID, + name: 'Evaluation Type', + options: objectUtils.typedKeys(PROPOSAL_STEP_LABELS).map((evaluationType) => ({ + color: 'propColorGray', + id: evaluationType, + value: evaluationType + })), + type: 'proposalEvaluationType' + } + ], + cards as Card[] + ).map((c) => c.id); + + cards = cards.filter((cp) => filteredCardsIds.includes(cp.id)); + } + + const cardTitles: Record = cards.reduce>((acc, c) => { + acc[c.id] = { title: c.title }; + return acc; + }, {}); + + if (viewBlock.fields.sortOptions?.length) { + cards = sortCards(cards as Card[], board, viewBlock, membersRecord, cardTitles); + } + + const visibleProperties = board.fields.cardProperties.filter( + (prop) => !viewBlock.fields.visiblePropertyIds || viewBlock.fields.visiblePropertyIds.includes(prop.id) + ); + + const titleProperty = visibleProperties.find((prop) => prop.id === Constants.titleColumnId); + if (!titleProperty) { + visibleProperties.unshift({ + id: Constants.titleColumnId, + name: 'Title', + type: 'text', + options: [], + readOnly: true + }); + } + + const csvData = cards.map((card) => { + return visibleProperties.reduce>((acc, prop) => { + const value = prop.id === Constants.titleColumnId ? card.title : card.fields.properties[prop.id]; + const displayValue = OctoUtils.propertyDisplayValue({ + block: card, + propertyValue: value as string, + propertyTemplate: prop, + formatters: { + date: formatDate, + dateTime: formatDateTime + }, + context: { + spaceDomain: space.domain, + users: membersRecord + } + }); + + acc[prop.name] = Array.isArray(displayValue) ? displayValue.join(', ') : String(displayValue || ''); + return acc; + }, {}); + }); + + const csvContent = stringify(csvData, { + header: true, + delimiter: '\t' + }); + + return csvContent; +} diff --git a/packages/scoutgame-ui/src/components/common/Profile/ProfileLinks.tsx b/packages/scoutgame-ui/src/components/common/Profile/ProfileLinks.tsx index 0b54d44a3c..6e94f4e20e 100644 --- a/packages/scoutgame-ui/src/components/common/Profile/ProfileLinks.tsx +++ b/packages/scoutgame-ui/src/components/common/Profile/ProfileLinks.tsx @@ -28,8 +28,8 @@ export function ProfileLinks({ > warpcast icon @@ -76,8 +76,8 @@ export function ProfileLinks({ moxie icon diff --git a/pages/api/spaces/[id]/proposals/export.ts b/pages/api/spaces/[id]/proposals/export.ts new file mode 100644 index 0000000000..eb9a2eacb2 --- /dev/null +++ b/pages/api/spaces/[id]/proposals/export.ts @@ -0,0 +1,26 @@ +import { exportProposals } from '@root/lib/proposals/exportProposals'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import nc from 'next-connect'; + +import { onError, onNoMatch, requireSpaceMembership, requireUser } from 'lib/middleware'; +import { withSessionRoute } from 'lib/session/withSession'; + +const handler = nc({ onError, onNoMatch }); + +handler + .use(requireUser) + .get(exportProposalsHandler) + .use( + requireSpaceMembership({ + adminOnly: false + }) + ); + +async function exportProposalsHandler(req: NextApiRequest, res: NextApiResponse) { + const spaceId = req.query.id as string; + const userId = req.session.user?.id; + const csvContent = await exportProposals({ spaceId, userId }); + return res.status(200).send(csvContent); +} + +export default withSessionRoute(handler); diff --git a/pages/api/spaces/[id]/proposals/my-work/export.ts b/pages/api/spaces/[id]/proposals/my-work/export.ts new file mode 100644 index 0000000000..011785d3ea --- /dev/null +++ b/pages/api/spaces/[id]/proposals/my-work/export.ts @@ -0,0 +1,20 @@ +import { exportMyProposals } from '@root/lib/proposals/exportMyProposals'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import nc from 'next-connect'; + +import { onError, onNoMatch, requireSpaceMembership } from 'lib/middleware'; +import { withSessionRoute } from 'lib/session/withSession'; + +const handler = nc({ onError, onNoMatch }); + +handler.get(exportUserProposalsController).use(requireSpaceMembership({ adminOnly: false })); + +async function exportUserProposalsController(req: NextApiRequest, res: NextApiResponse) { + const userId = req.session.user?.id; + const spaceId = req.query.id as string; + const csvContent = await exportMyProposals({ spaceId, userId }); + + return res.status(200).send(csvContent); +} + +export default withSessionRoute(handler); diff --git a/pages/api/spaces/[id]/proposals/work.ts b/pages/api/spaces/[id]/proposals/my-work/index.ts similarity index 100% rename from pages/api/spaces/[id]/proposals/work.ts rename to pages/api/spaces/[id]/proposals/my-work/index.ts