New file |
| | |
| | | import { Draggable } from '@hello-pangea/dnd'; |
| | | import { Box, Card, Typography } from '@mui/material'; |
| | | import { ReferenceField, useRedirect } from 'react-admin'; |
| | | import { CompanyAvatar } from '../companies/CompanyAvatar'; |
| | | import { Deal } from '../types'; |
| | | |
| | | export const MissionCard = ({ deal, index }) => { |
| | | if (!deal) return null; |
| | | |
| | | return ( |
| | | <Draggable draggableId={String(deal.id)} index={index}> |
| | | {(provided, snapshot) => ( |
| | | <DealCardContent |
| | | provided={provided} |
| | | snapshot={snapshot} |
| | | deal={deal} |
| | | /> |
| | | )} |
| | | </Draggable> |
| | | ); |
| | | }; |
| | | |
| | | export const DealCardContent = ({ |
| | | provided, |
| | | snapshot, |
| | | deal, |
| | | }) => { |
| | | const redirect = useRedirect(); |
| | | const handleClick = () => { |
| | | redirect(`/deals/${deal.id}/show`, undefined, undefined, undefined, { |
| | | _scrollToTop: false, |
| | | }); |
| | | }; |
| | | |
| | | return ( |
| | | <Box |
| | | sx={{ marginBottom: 1, cursor: 'pointer' }} |
| | | {...provided?.draggableProps} |
| | | {...provided?.dragHandleProps} |
| | | ref={provided?.innerRef} |
| | | onClick={handleClick} |
| | | > |
| | | <Card |
| | | style={{ |
| | | opacity: snapshot?.isDragging ? 0.9 : 1, |
| | | transform: snapshot?.isDragging ? 'rotate(-2deg)' : '', |
| | | }} |
| | | elevation={snapshot?.isDragging ? 3 : 1} |
| | | > |
| | | <Box padding={1} display="flex"> |
| | | <ReferenceField |
| | | source="company_id" |
| | | record={deal} |
| | | reference="companies" |
| | | link={false} |
| | | > |
| | | <CompanyAvatar width={20} height={20} /> |
| | | </ReferenceField> |
| | | <Box sx={{ marginLeft: 1 }}> |
| | | <Typography variant="body2" gutterBottom> |
| | | {deal.name} |
| | | </Typography> |
| | | <Typography variant="caption" color="textSecondary"> |
| | | {deal.amount.toLocaleString('en-US', { |
| | | notation: 'compact', |
| | | style: 'currency', |
| | | currency: 'USD', |
| | | currencyDisplay: 'narrowSymbol', |
| | | minimumSignificantDigits: 3, |
| | | })} |
| | | {deal.category ? `, ${deal.category}` : ''} |
| | | </Typography> |
| | | </Box> |
| | | </Box> |
| | | </Card> |
| | | </Box> |
| | | ); |
| | | }; |
New file |
| | |
| | | import { Droppable } from '@hello-pangea/dnd'; |
| | | import { Box, Stack, Typography } from '@mui/material'; |
| | | |
| | | import { Deal } from '../types'; |
| | | import { DealCard } from './MissionCard'; |
| | | import { useConfigurationContext } from '../root/ConfigurationContext'; |
| | | import { findDealLabel } from './deal'; |
| | | |
| | | export const MissionColumn = ({ stage, deals, }) => { |
| | | const totalAmount = deals.reduce((sum, deal) => sum + deal.amount, 0); |
| | | |
| | | const { dealStages } = useConfigurationContext(); |
| | | return ( |
| | | <Box |
| | | sx={{ |
| | | flex: 1, |
| | | paddingTop: '8px', |
| | | paddingBottom: '16px', |
| | | bgcolor: '#eaeaee', |
| | | '&:first-of-type': { |
| | | paddingLeft: '5px', |
| | | borderTopLeftRadius: 5, |
| | | }, |
| | | '&:last-of-type': { |
| | | paddingRight: '5px', |
| | | borderTopRightRadius: 5, |
| | | }, |
| | | }} |
| | | > |
| | | <Stack alignItems="center"> |
| | | <Typography variant="subtitle1"> |
| | | {findDealLabel(dealStages, stage)} |
| | | </Typography> |
| | | <Typography |
| | | variant="subtitle1" |
| | | color="text.secondary" |
| | | fontSize="small" |
| | | > |
| | | {totalAmount.toLocaleString('en-US', { |
| | | notation: 'compact', |
| | | style: 'currency', |
| | | currency: 'USD', |
| | | currencyDisplay: 'narrowSymbol', |
| | | minimumSignificantDigits: 3, |
| | | })} |
| | | </Typography> |
| | | </Stack> |
| | | <Droppable droppableId={stage}> |
| | | {(droppableProvided, snapshot) => ( |
| | | <Box |
| | | ref={droppableProvided.innerRef} |
| | | {...droppableProvided.droppableProps} |
| | | className={ |
| | | snapshot.isDraggingOver ? ' isDraggingOver' : '' |
| | | } |
| | | sx={{ |
| | | display: 'flex', |
| | | flexDirection: 'column', |
| | | borderRadius: 1, |
| | | padding: '5px', |
| | | '&.isDraggingOver': { |
| | | bgcolor: '#dadadf', |
| | | }, |
| | | }} |
| | | > |
| | | {deals.map((deal, index) => ( |
| | | <DealCard key={deal.id} deal={deal} index={index} /> |
| | | ))} |
| | | {droppableProvided.placeholder} |
| | | </Box> |
| | | )} |
| | | </Droppable> |
| | | </Box> |
| | | ); |
| | | }; |
| | |
| | | |
| | | export const MissionEmpty = ({ children }) => { |
| | | const location = useLocation(); |
| | | const matchCreate = matchPath('/deals/create', location.pathname); |
| | | const appbarHeight = useAppBarHeight(); |
| | | const matchCreate = matchPath('/mission/create', location.pathname); |
| | | const appBarHeight = useAppBarHeight(); |
| | | |
| | | // get Contact data |
| | | const { data: contacts, isPending: contactsLoading } = useGetList<Contact>( |
| | | const { data: contacts, isPending: contactsLoading } = useGetList < Contact > ( |
| | | 'contacts', |
| | | { |
| | | pagination: { page: 1, perPage: 1 }, |
New file |
| | |
| | | import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; |
| | | import { Box } from '@mui/material'; |
| | | import isEqual from 'lodash/isEqual'; |
| | | import { useEffect, useState } from 'react'; |
| | | import { DataProvider, useDataProvider, useListContext } from 'react-admin'; |
| | | |
| | | import { Deal } from '../types'; |
| | | import { DealColumn } from './MissionColumn'; |
| | | import { DealsByStage, getDealsByStage } from './stages'; |
| | | import { useConfigurationContext } from '../root/ConfigurationContext'; |
| | | |
| | | export const MissionListContent = () => { |
| | | const { dealStages } = useConfigurationContext(); |
| | | const { data: unorderedDeals, isPending, refetch } = useListContext(); |
| | | const dataProvider = useDataProvider(); |
| | | |
| | | const [dealsByStage, setDealsByStage] = useState( |
| | | getDealsByStage([], dealStages) |
| | | ); |
| | | |
| | | useEffect(() => { |
| | | if (unorderedDeals) { |
| | | const newDealsByStage = getDealsByStage(unorderedDeals, dealStages); |
| | | if (!isEqual(newDealsByStage, dealsByStage)) { |
| | | setDealsByStage(newDealsByStage); |
| | | } |
| | | } |
| | | // eslint-disable-next-line react-hooks/exhaustive-deps |
| | | }, [unorderedDeals]); |
| | | |
| | | if (isPending) return null; |
| | | |
| | | const onDragEnd = result => { |
| | | const { destination, source } = result; |
| | | |
| | | if (!destination) { |
| | | return; |
| | | } |
| | | |
| | | if ( |
| | | destination.droppableId === source.droppableId && |
| | | destination.index === source.index |
| | | ) { |
| | | return; |
| | | } |
| | | |
| | | const sourceStage = source.droppableId; |
| | | const destinationStage = destination.droppableId; |
| | | const sourceDeal = dealsByStage[sourceStage][source.index]; |
| | | const destinationDeal = dealsByStage[destinationStage][ |
| | | destination.index |
| | | ] ?? { |
| | | stage: destinationStage, |
| | | index: undefined, // undefined if dropped after the last item |
| | | }; |
| | | |
| | | // compute local state change synchronously |
| | | setDealsByStage( |
| | | updateDealStageLocal( |
| | | sourceDeal, |
| | | { stage: sourceStage, index: source.index }, |
| | | { stage: destinationStage, index: destination.index }, |
| | | dealsByStage |
| | | ) |
| | | ); |
| | | |
| | | // persist the changes |
| | | updateDealStage(sourceDeal, destinationDeal, dataProvider).then(() => { |
| | | refetch(); |
| | | }); |
| | | }; |
| | | |
| | | return ( |
| | | <DragDropContext onDragEnd={onDragEnd}> |
| | | <Box display="flex"> |
| | | {dealStages.map(stage => ( |
| | | <DealColumn |
| | | stage={stage.value} |
| | | deals={dealsByStage[stage.value]} |
| | | key={stage.value} |
| | | /> |
| | | ))} |
| | | </Box> |
| | | </DragDropContext> |
| | | ); |
| | | }; |
| | | |
| | | const updateDealStageLocal = ( |
| | | sourceDeal, |
| | | source, |
| | | destination, |
| | | dealsByStage |
| | | ) => { |
| | | if (source.stage === destination.stage) { |
| | | // moving deal inside the same column |
| | | const column = dealsByStage[source.stage]; |
| | | column.splice(source.index, 1); |
| | | column.splice(destination.index ?? column.length + 1, 0, sourceDeal); |
| | | return { |
| | | ...dealsByStage, |
| | | [destination.stage]: column, |
| | | }; |
| | | } else { |
| | | // moving deal across columns |
| | | const sourceColumn = dealsByStage[source.stage]; |
| | | const destinationColumn = dealsByStage[destination.stage]; |
| | | sourceColumn.splice(source.index, 1); |
| | | destinationColumn.splice( |
| | | destination.index ?? destinationColumn.length + 1, |
| | | 0, |
| | | sourceDeal |
| | | ); |
| | | return { |
| | | ...dealsByStage, |
| | | [source.stage]: sourceColumn, |
| | | [destination.stage]: destinationColumn, |
| | | }; |
| | | } |
| | | }; |
| | | |
| | | const updateDealStage = async ( |
| | | source, |
| | | destination, |
| | | dataProvider |
| | | ) => { |
| | | if (source.stage === destination.stage) { |
| | | // moving deal inside the same column |
| | | // Fetch all the deals in this stage (because the list may be filtered, but we need to update even non-filtered deals) |
| | | const { data: columnDeals } = await dataProvider.getList('deals', { |
| | | sort: { field: 'index', order: 'ASC' }, |
| | | pagination: { page: 1, perPage: 100 }, |
| | | filter: { stage: source.stage }, |
| | | }); |
| | | const destinationIndex = destination.index ?? columnDeals.length + 1; |
| | | |
| | | if (source.index > destinationIndex) { |
| | | // deal moved up, eg |
| | | // dest src |
| | | // <------ |
| | | // [4, 7, 23, 5] |
| | | await Promise.all([ |
| | | // for all deals between destinationIndex and source.index, increase the index |
| | | ...columnDeals |
| | | .filter( |
| | | deal => |
| | | deal.index >= destinationIndex && |
| | | deal.index < source.index |
| | | ) |
| | | .map(deal => |
| | | dataProvider.update('deals', { |
| | | id: deal.id, |
| | | data: { index: deal.index + 1 }, |
| | | previousData: deal, |
| | | }) |
| | | ), |
| | | // for the deal that was moved, update its index |
| | | dataProvider.update('deals', { |
| | | id: source.id, |
| | | data: { index: destinationIndex }, |
| | | previousData: source, |
| | | }), |
| | | ]); |
| | | } else { |
| | | // deal moved down, e.g |
| | | // src dest |
| | | // ------> |
| | | // [4, 7, 23, 5] |
| | | await Promise.all([ |
| | | // for all deals between source.index and destinationIndex, decrease the index |
| | | ...columnDeals |
| | | .filter( |
| | | deal => |
| | | deal.index <= destinationIndex && |
| | | deal.index > source.index |
| | | ) |
| | | .map(deal => |
| | | dataProvider.update('deals', { |
| | | id: deal.id, |
| | | data: { index: deal.index - 1 }, |
| | | previousData: deal, |
| | | }) |
| | | ), |
| | | // for the deal that was moved, update its index |
| | | dataProvider.update('deals', { |
| | | id: source.id, |
| | | data: { index: destinationIndex }, |
| | | previousData: source, |
| | | }), |
| | | ]); |
| | | } |
| | | } else { |
| | | // moving deal across columns |
| | | // Fetch all the deals in both stages (because the list may be filtered, but we need to update even non-filtered deals) |
| | | const [{ data: sourceDeals }, { data: destinationDeals }] = |
| | | await Promise.all([ |
| | | dataProvider.getList('deals', { |
| | | sort: { field: 'index', order: 'ASC' }, |
| | | pagination: { page: 1, perPage: 100 }, |
| | | filter: { stage: source.stage }, |
| | | }), |
| | | dataProvider.getList('deals', { |
| | | sort: { field: 'index', order: 'ASC' }, |
| | | pagination: { page: 1, perPage: 100 }, |
| | | filter: { stage: destination.stage }, |
| | | }), |
| | | ]); |
| | | const destinationIndex = |
| | | destination.index ?? destinationDeals.length + 1; |
| | | |
| | | await Promise.all([ |
| | | // decrease index on the deals after the source index in the source columns |
| | | ...sourceDeals |
| | | .filter(deal => deal.index > source.index) |
| | | .map(deal => |
| | | dataProvider.update('deals', { |
| | | id: deal.id, |
| | | data: { index: deal.index - 1 }, |
| | | previousData: deal, |
| | | }) |
| | | ), |
| | | // increase index on the deals after the destination index in the destination columns |
| | | ...destinationDeals |
| | | .filter(deal => deal.index >= destinationIndex) |
| | | .map(deal => |
| | | dataProvider.update('deals', { |
| | | id: deal.id, |
| | | data: { index: deal.index + 1 }, |
| | | previousData: deal, |
| | | }) |
| | | ), |
| | | // change the dragged deal to take the destination index and column |
| | | dataProvider.update('deals', { |
| | | id: source.id, |
| | | data: { |
| | | index: destinationIndex, |
| | | stage: destination.stage, |
| | | }, |
| | | previousData: source, |
| | | }), |
| | | ]); |
| | | } |
| | | }; |
| | |
| | | MissionVo vo = new MissionVo(); |
| | | |
| | | vo.setGroupNo(groupNo); |
| | | |
| | | result.add(vo); |
| | | } |
| | | |
| | | return result; |