From 114ad96d5819d9c3afd87095e6183c5c8d69ed5b Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:45:52 +0200 Subject: [PATCH 1/8] create utility function getCanvassers to reuse logic, implement new Person icon and canvassers number in CanvassAssignmentListItem and CanvassAssignmentOverviewlistItem --- .../CanvassAssignmentOverviewListItem.tsx | 17 +++++++++--- .../items/CanvassAssignmentListItem.tsx | 14 +++++++--- src/features/canvassAssignments/types.ts | 6 +++++ .../utils/getCanvassers.tsx | 24 +++++++++++++++++ .../[canvassAssId]/canvassers.tsx | 26 +++---------------- 5 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 src/features/canvassAssignments/utils/getCanvassers.tsx diff --git a/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx b/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx index 3220f3a92..dcec47464 100644 --- a/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx +++ b/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx @@ -1,9 +1,12 @@ import { FC } from 'react'; -import { Map } from '@mui/icons-material'; +import { Map, Person } from '@mui/icons-material'; import { CanvassAssignmentActivity } from 'features/campaigns/types'; import getStatusColor from 'features/campaigns/utils/getStatusColor'; import OverviewListItem from './OverviewListItem'; +import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; +import getCanvassers from 'features/canvassAssignments/utils/getCanvassers'; +import { useNumericRouteParams } from 'core/hooks'; type Props = { activity: CanvassAssignmentActivity; @@ -15,18 +18,26 @@ const CanvassAssignmentOverviewListItem: FC = ({ focusDate, }) => { const assignment = activity.data; + const { orgId } = useNumericRouteParams(); + + const allSessions = useCanvassSessions(orgId, assignment.id).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === assignment.id + ); + + const canvassers = getCanvassers(sessions); return ( = ({ caId, orgId }) => { const { data: assignment } = useCanvassAssignment(orgId, caId); + const allSessions = useCanvassSessions(orgId, caId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === caId + ); + if (!assignment) { return null; } + const canvassers = getCanvassers(sessions); const color = STATUS_COLORS.GRAY; return ( ); diff --git a/src/features/canvassAssignments/types.ts b/src/features/canvassAssignments/types.ts index 814def005..73772a5e3 100644 --- a/src/features/canvassAssignments/types.ts +++ b/src/features/canvassAssignments/types.ts @@ -1,6 +1,12 @@ import { ZetkinArea } from 'features/areas/types'; import { ZetkinPerson } from 'utils/types/zetkin'; +export type CanvasserInfo = { + id: number; + person: ZetkinPerson; + sessions: ZetkinCanvassSession[]; +}; + export type ZetkinMetric = { definesDone: boolean; description: string; diff --git a/src/features/canvassAssignments/utils/getCanvassers.tsx b/src/features/canvassAssignments/utils/getCanvassers.tsx new file mode 100644 index 000000000..d82b8e3c3 --- /dev/null +++ b/src/features/canvassAssignments/utils/getCanvassers.tsx @@ -0,0 +1,24 @@ +import { CanvasserInfo, ZetkinCanvassSession } from '../types'; + +const getCanvassers = (sessions: ZetkinCanvassSession[]) => { + const sessionsByPersonId: Record = {}; + + sessions.forEach((session) => { + if (session.assignee && session.assignee.id) { + if (!sessionsByPersonId[session.assignee.id]) { + sessionsByPersonId[session.assignee.id] = { + id: session.assignee.id, + person: session.assignee, + sessions: [session], + }; + } else { + sessionsByPersonId[session.assignee.id].sessions.push(session); + } + } + }); + + const canvassers = Object.values(sessionsByPersonId); + return canvassers; +}; + +export default getCanvassers; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx index 7b1a13e2f..2bdb2666d 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx @@ -3,13 +3,13 @@ import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import { ZetkinPerson } from 'utils/types/zetkin'; import ZUIAvatar from 'zui/ZUIAvatar'; import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; import { AREAS } from 'utils/featureFlags'; -import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; +import { CanvasserInfo } from 'features/canvassAssignments/types'; import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; +import getCanvassers from 'features/canvassAssignments/utils/getCanvassers'; const scaffoldOptions = { authLevelRequired: 2, @@ -28,12 +28,6 @@ type Props = { orgId: string; }; -type CanvasserInfo = { - id: number; - person: ZetkinPerson; - sessions: ZetkinCanvassSession[]; -}; - const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, @@ -44,21 +38,7 @@ const CanvassAssignmentPage: PageWithLayout = ({ (session) => session.assignment.id === canvassAssId ); - const sessionsByPersonId: Record = {}; - - sessions.forEach((session) => { - if (!sessionsByPersonId[session.assignee.id]) { - sessionsByPersonId[session.assignee.id] = { - id: session.assignee.id, - person: session.assignee, - sessions: [session], - }; - } else { - sessionsByPersonId[session.assignee.id].sessions.push(session); - } - }); - - const canvassers = Object.values(sessionsByPersonId); + const canvassers = getCanvassers(sessions); const columns: GridColDef[] = [ { From b60770d1ea7244d184c6a1a0d23f4371f582782b Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:36:07 +0200 Subject: [PATCH 2/8] implement areas and canvassers in header --- .../layouts/CanvassAssignmentLayout.tsx | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index c35e1dd53..84ca41d51 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -1,10 +1,17 @@ -import { FC, ReactNode } from 'react'; +import { Box } from '@mui/system'; +import { Typography } from '@mui/material'; import { useRouter } from 'next/router'; +import { FC, ReactNode } from 'react'; +import { Pentagon, People } from '@mui/icons-material'; +import getCanvassers from '../utils/getCanvassers'; import TabbedLayout from 'utils/layout/TabbedLayout'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; -import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; import useCanvassAssignmentMutations from '../hooks/useCanvassAssignmentMutations'; +import useCanvassSessions from '../hooks/useCanvassSessions'; +import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; +import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; +import ZUIFuture from 'zui/ZUIFuture'; type CanvassAssignmentLayoutProps = { campId: number; @@ -26,6 +33,15 @@ const CanvassAssignmentLayout: FC = ({ canvassAssId ); + const allSessions = useCanvassSessions(orgId, canvassAssId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === canvassAssId + ); + + const stats = useCanvassAssignmentStats(orgId, canvassAssId); + + const canvassers = getCanvassers(sessions); + const isPlanTab = path.endsWith('/plan'); if (!canvassAssignment) { @@ -37,6 +53,24 @@ const CanvassAssignmentLayout: FC = ({ baseHref={`/organize/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}`} defaultTab="/" fixedHeight={isPlanTab} + subtitle={ + + + {(data) => ( + + + {data.num_areas} Area(s) + + )} + + + + + {canvassers.length} Canvasser(s) + + + + } tabs={[ { href: '/', label: 'Overview' }, { href: '/plan', label: 'Plan' }, From 42bbe7ec454ad2f978424b5cec1a524123bca7ec Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:59:36 +0200 Subject: [PATCH 3/8] add start_date and end_date to ZetkinCanvassAssignment type, refactor routes, implement new component AssignmentStatuschip and DatePicker in CanvassAssg header, add new hook useCanvassAssignment status, update models --- .../[canvassAssId]/route.ts | 23 +++++++- .../orgs/[orgId]/canvassassignments/route.ts | 4 ++ .../components/AssignmentStatusChip.tsx | 58 +++++++++++++++++++ .../hooks/useCanvassAssignmentStatus.tsx | 41 +++++++++++++ .../layouts/CanvassAssignmentLayout.tsx | 19 ++++++ src/features/canvassAssignments/models.ts | 10 ++++ src/features/canvassAssignments/types.ts | 2 + 7 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/features/canvassAssignments/components/AssignmentStatusChip.tsx create mode 100644 src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index aaa8395f6..e3ea69546 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -36,6 +36,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const canvassAssignment: ZetkinCanvassAssignment = { campaign: { id: canvassAssignmentModel.campId }, + end_date: canvassAssignmentModel.end_date, id: canvassAssignmentModel._id.toString(), metrics: (canvassAssignmentModel.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, @@ -45,6 +46,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { question: metric.question, })), organization: { id: orgId }, + start_date: canvassAssignmentModel.start_date, title: canvassAssignmentModel.title, }; @@ -64,7 +66,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { await mongoose.connect(process.env.MONGODB_URL || ''); const payload = await request.json(); - const { metrics: newMetrics, title } = payload; + const { metrics: newMetrics, title, start_date, end_date } = payload; if (newMetrics) { // Find existing metrics to remove @@ -118,10 +120,25 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { } } } + type UpdateFieldsType = Partial< + Pick + >; + + const updateFields: UpdateFieldsType = {}; + + if (title !== null) { + updateFields.title = title; + } + if (start_date !== null) { + updateFields.start_date = start_date; + } + if (end_date !== null) { + updateFields.end_date = end_date; + } await CanvassAssignmentModel.updateOne( { _id: params.canvassAssId }, - { title } + updateFields ); const model = await CanvassAssignmentModel.findById( params.canvassAssId @@ -134,6 +151,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { campaign: { id: model.campId }, + end_date: model.end_date, id: model._id.toString(), metrics: (model.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, @@ -143,6 +161,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { question: metric.question, })), organization: { id: orgId }, + start_date: model.start_date, title: model.title, }, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts index 2171ae1f6..be117e622 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts @@ -27,6 +27,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { campaign: { id: assignment.campId, }, + end_date: assignment.end_date, id: assignment._id.toString(), metrics: (assignment.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, @@ -38,6 +39,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { organization: { id: orgId, }, + start_date: assignment.start_date, title: assignment.title, })), }); @@ -69,6 +71,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { campaign: { id: model.campId }, + end_date: model.end_date, id: model._id.toString(), metrics: model.metrics.map((metric) => ({ definesDone: metric.definesDone || false, @@ -78,6 +81,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { question: metric.question, })), organization: { id: orgId }, + start_date: model.start_date, title: model.title, }, }); diff --git a/src/features/canvassAssignments/components/AssignmentStatusChip.tsx b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx new file mode 100644 index 000000000..0a7a66570 --- /dev/null +++ b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import { makeStyles } from '@mui/styles'; + +import { CanvassAssignmentState } from '../hooks/useCanvassAssignmentStatus'; + +interface AssigmentStatusChipProps { + state: CanvassAssignmentState; +} + +const capitalizeFirstLetter = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +const useStyles = makeStyles((theme) => ({ + chip: { + alignItems: 'center', + borderRadius: '2em', + color: 'white', + display: 'inline-flex', + fontSize: 14, + fontWeight: 'bold', + padding: '0.5em 0.7em', + }, + closed: { + backgroundColor: theme.palette.error.main, + }, + draft: { + backgroundColor: theme.palette.grey[500], + }, + open: { + backgroundColor: theme.palette.success.main, + }, + scheduled: { + backgroundColor: theme.palette.statusColors.blue, + }, +})); + +const AssignmentStatusChip: FC = ({ state }) => { + const classes = useStyles(); + + const classMap: Record = { + [CanvassAssignmentState.CLOSED]: classes.closed, + [CanvassAssignmentState.OPEN]: classes.open, + [CanvassAssignmentState.SCHEDULED]: classes.scheduled, + [CanvassAssignmentState.UNKNOWN]: classes.draft, + }; + + const colorClassName = classMap[state]; + + return ( + + {capitalizeFirstLetter(state)} + + ); +}; + +export default AssignmentStatusChip; diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx b/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx new file mode 100644 index 000000000..fa6753726 --- /dev/null +++ b/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx @@ -0,0 +1,41 @@ +import useCanvassAssignment from './useCanvassAssignment'; + +export enum CanvassAssignmentState { + CLOSED = 'closed', + OPEN = 'open', + SCHEDULED = 'scheduled', + UNKNOWN = 'unkown', +} + +export default function useCanvassAssignmentStatus( + orgId: number, + canvassId: string +): CanvassAssignmentState { + const { data: canvassAssignment } = useCanvassAssignment(orgId, canvassId); + + if (!canvassAssignment) { + return CanvassAssignmentState.UNKNOWN; + } + + if (canvassAssignment.start_date) { + const startDate = new Date(canvassAssignment.start_date); + const now = new Date(); + + if (startDate > now) { + return CanvassAssignmentState.SCHEDULED; + } + + if (canvassAssignment.end_date) { + const endDate = new Date(canvassAssignment.end_date); + + if (endDate < now) { + return CanvassAssignmentState.CLOSED; + } + + if (startDate <= now && endDate > now) { + return CanvassAssignmentState.OPEN; + } + } + } + return CanvassAssignmentState.UNKNOWN; +} diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index 84ca41d51..c2d3c970d 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -12,6 +12,9 @@ import useCanvassSessions from '../hooks/useCanvassSessions'; import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; import ZUIFuture from 'zui/ZUIFuture'; +import AssignmentStatusChip from '../components/AssignmentStatusChip'; +import useCanvassAssignmentStatus from '../hooks/useCanvassAssignmentStatus'; +import ZUIDateRangePicker from 'zui/ZUIDateRangePicker/ZUIDateRangePicker'; type CanvassAssignmentLayoutProps = { campId: number; @@ -39,6 +42,7 @@ const CanvassAssignmentLayout: FC = ({ ); const stats = useCanvassAssignmentStats(orgId, canvassAssId); + const state = useCanvassAssignmentStatus(orgId, canvassAssId); const canvassers = getCanvassers(sessions); @@ -51,10 +55,25 @@ const CanvassAssignmentLayout: FC = ({ return ( { + updateCanvassAssignment({ + end_date: endDate, + start_date: startDate, + }); + }} + startDate={canvassAssignment.start_date || null} + /> + } defaultTab="/" fixedHeight={isPlanTab} subtitle={ + + + {(data) => ( diff --git a/src/features/canvassAssignments/models.ts b/src/features/canvassAssignments/models.ts index 09f94f6b0..7631b067b 100644 --- a/src/features/canvassAssignments/models.ts +++ b/src/features/canvassAssignments/models.ts @@ -4,6 +4,7 @@ import { ZetkinCanvassAssignee, ZetkinMetric, ZetkinPlace } from './types'; type ZetkinCanvassAssignmentModelType = { campId: number; + end_date: string | null; id: number; metrics: (Omit & { _id: string })[]; orgId: number; @@ -11,6 +12,7 @@ type ZetkinCanvassAssignmentModelType = { areaId: string; personId: number; }[]; + start_date: string | null; title: string | null; }; @@ -19,6 +21,10 @@ type ZetkinPlaceModelType = Omit; const canvassAssignmentSchema = new mongoose.Schema({ campId: Number, + end_date: { + default: null, + type: String, + }, metrics: [ { definesDone: Boolean, @@ -35,6 +41,10 @@ const canvassAssignmentSchema = personId: Number, }, ], + start_date: { + default: null, + type: String, + }, title: String, }); diff --git a/src/features/canvassAssignments/types.ts b/src/features/canvassAssignments/types.ts index 73772a5e3..7fb25eb68 100644 --- a/src/features/canvassAssignments/types.ts +++ b/src/features/canvassAssignments/types.ts @@ -19,11 +19,13 @@ export type ZetkinCanvassAssignment = { campaign: { id: number; }; + end_date: string | null; id: string; metrics: ZetkinMetric[]; organization: { id: number; }; + start_date: string | null; title: string | null; }; From a0e6d3e81ffe2b52db1ce58f237b110a44408b6d Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:20:47 +0200 Subject: [PATCH 4/8] add actionButton to start and end a canvass assignment --- .../layouts/CanvassAssignmentLayout.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index c2d3c970d..a9d51b767 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -1,9 +1,11 @@ import { Box } from '@mui/system'; -import { Typography } from '@mui/material'; +import dayjs from 'dayjs'; import { useRouter } from 'next/router'; +import { Button, Typography } from '@mui/material'; import { FC, ReactNode } from 'react'; import { Pentagon, People } from '@mui/icons-material'; +import AssignmentStatusChip from '../components/AssignmentStatusChip'; import getCanvassers from '../utils/getCanvassers'; import TabbedLayout from 'utils/layout/TabbedLayout'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; @@ -12,9 +14,10 @@ import useCanvassSessions from '../hooks/useCanvassSessions'; import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; import ZUIFuture from 'zui/ZUIFuture'; -import AssignmentStatusChip from '../components/AssignmentStatusChip'; -import useCanvassAssignmentStatus from '../hooks/useCanvassAssignmentStatus'; import ZUIDateRangePicker from 'zui/ZUIDateRangePicker/ZUIDateRangePicker'; +import useCanvassAssignmentStatus, { + CanvassAssignmentState, +} from '../hooks/useCanvassAssignmentStatus'; type CanvassAssignmentLayoutProps = { campId: number; @@ -54,6 +57,31 @@ const CanvassAssignmentLayout: FC = ({ return ( + updateCanvassAssignment({ + end_date: dayjs().format('YYYY-MM-DD'), + }) + } + variant="outlined" + > + {'End Assignment'} + + ) : ( + + ) + } baseHref={`/organize/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}`} belowActionButtons={ Date: Thu, 17 Oct 2024 11:15:27 +0200 Subject: [PATCH 5/8] add draft as a status --- .../components/AssignmentStatusChip.tsx | 1 + .../hooks/useCanvassAssignmentStatus.tsx | 41 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/features/canvassAssignments/components/AssignmentStatusChip.tsx b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx index 0a7a66570..8c62b85d3 100644 --- a/src/features/canvassAssignments/components/AssignmentStatusChip.tsx +++ b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx @@ -44,6 +44,7 @@ const AssignmentStatusChip: FC = ({ state }) => { [CanvassAssignmentState.OPEN]: classes.open, [CanvassAssignmentState.SCHEDULED]: classes.scheduled, [CanvassAssignmentState.UNKNOWN]: classes.draft, + [CanvassAssignmentState.DRAFT]: classes.draft, }; const colorClassName = classMap[state]; diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx b/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx index fa6753726..119e08864 100644 --- a/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx +++ b/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx @@ -1,10 +1,13 @@ +import dayjs from 'dayjs'; + import useCanvassAssignment from './useCanvassAssignment'; export enum CanvassAssignmentState { CLOSED = 'closed', + DRAFT = 'draft', OPEN = 'open', SCHEDULED = 'scheduled', - UNKNOWN = 'unkown', + UNKNOWN = 'unknown', } export default function useCanvassAssignmentStatus( @@ -17,25 +20,33 @@ export default function useCanvassAssignmentStatus( return CanvassAssignmentState.UNKNOWN; } - if (canvassAssignment.start_date) { - const startDate = new Date(canvassAssignment.start_date); - const now = new Date(); + const now = dayjs(); - if (startDate > now) { - return CanvassAssignmentState.SCHEDULED; - } + if (!canvassAssignment.start_date) { + return CanvassAssignmentState.DRAFT; + } + + const startDate = dayjs(canvassAssignment.start_date); + + if (startDate.isAfter(now)) { + return CanvassAssignmentState.SCHEDULED; + } - if (canvassAssignment.end_date) { - const endDate = new Date(canvassAssignment.end_date); + if (canvassAssignment.end_date) { + const endDate = dayjs(canvassAssignment.end_date); - if (endDate < now) { - return CanvassAssignmentState.CLOSED; - } + if (endDate.isBefore(now)) { + return CanvassAssignmentState.CLOSED; + } - if (startDate <= now && endDate > now) { - return CanvassAssignmentState.OPEN; - } + if (startDate.isBefore(now) || startDate.isSame(now)) { + return CanvassAssignmentState.OPEN; } } + + if (!canvassAssignment.end_date && startDate.isBefore(now)) { + return CanvassAssignmentState.OPEN; + } + return CanvassAssignmentState.UNKNOWN; } From 060a3f055da7fc377c99099dae08c70e2a613bea Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:17:31 +0200 Subject: [PATCH 6/8] add new hook to handle start and end of assignment, fix logic in route to set start and end time --- .../[canvassAssId]/route.ts | 16 ++-- .../hooks/useStartEndAssignment.tsx | 85 +++++++++++++++++++ .../layouts/CanvassAssignmentLayout.tsx | 24 ++---- 3 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 src/features/canvassAssignments/hooks/useStartEndAssignment.tsx diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index e3ea69546..bf54cfdd8 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -129,17 +129,21 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { if (title !== null) { updateFields.title = title; } - if (start_date !== null) { + + if (Object.prototype.hasOwnProperty.call(payload, 'start_date')) { updateFields.start_date = start_date; } - if (end_date !== null) { + + if (Object.prototype.hasOwnProperty.call(payload, 'end_date')) { updateFields.end_date = end_date; } - await CanvassAssignmentModel.updateOne( - { _id: params.canvassAssId }, - updateFields - ); + if (Object.keys(updateFields).length > 0) { + await CanvassAssignmentModel.updateOne( + { _id: params.canvassAssId }, + { $set: updateFields } + ); + } const model = await CanvassAssignmentModel.findById( params.canvassAssId ).populate('metrics'); diff --git a/src/features/canvassAssignments/hooks/useStartEndAssignment.tsx b/src/features/canvassAssignments/hooks/useStartEndAssignment.tsx new file mode 100644 index 000000000..2c78dd378 --- /dev/null +++ b/src/features/canvassAssignments/hooks/useStartEndAssignment.tsx @@ -0,0 +1,85 @@ +import dayjs from 'dayjs'; + +import useCanvassAssignment from './useCanvassAssignment'; +import useCanvassAssignmentMutations from './useCanvassAssignmentMutations'; + +export default function useStartEndAssignment( + orgId: number, + canvassAssId: string +) { + const canvassAssignment = useCanvassAssignment(orgId, canvassAssId); + const updateCanvassAssignment = useCanvassAssignmentMutations( + orgId, + canvassAssId + ); + + const endAssignment = () => { + if (!canvassAssignment.data) { + return; + } + + const now = dayjs(); + const today = now.format('YYYY-MM-DD'); + + updateCanvassAssignment({ + end_date: today, + }); + }; + + const startAssignment = () => { + if (!canvassAssignment.data) { + return; + } + + const now = dayjs(); + const today = now.format('YYYY-MM-DD'); + + const { start_date: startStr, end_date: endStr } = canvassAssignment.data; + + if (!startStr && !endStr) { + updateCanvassAssignment({ + start_date: today, + }); + } else if (!startStr) { + const endDate = dayjs(endStr); + if (endDate.isBefore(today)) { + updateCanvassAssignment({ + end_date: null, + start_date: today, + }); + } else if (endDate.isAfter(today)) { + updateCanvassAssignment({ + start_date: today, + }); + } + } else if (!endStr) { + const startDate = dayjs(startStr); + if (startDate.isAfter(today)) { + updateCanvassAssignment({ + start_date: today, + }); + } + } else { + const startDate = dayjs(startStr); + const endDate = dayjs(endStr); + + if ( + (startDate.isBefore(today) || startDate.isSame(today)) && + (endDate.isBefore(today) || endDate.isSame(today)) + ) { + updateCanvassAssignment({ + end_date: null, + }); + } else if (startDate.isAfter(today) && endDate.isAfter(today)) { + updateCanvassAssignment({ + start_date: today, + }); + } + } + }; + + return { + endAssignment, + startAssignment, + }; +} diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index a9d51b767..5d038f84c 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -1,5 +1,4 @@ import { Box } from '@mui/system'; -import dayjs from 'dayjs'; import { useRouter } from 'next/router'; import { Button, Typography } from '@mui/material'; import { FC, ReactNode } from 'react'; @@ -12,6 +11,7 @@ import useCanvassAssignment from '../hooks/useCanvassAssignment'; import useCanvassAssignmentMutations from '../hooks/useCanvassAssignmentMutations'; import useCanvassSessions from '../hooks/useCanvassSessions'; import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; +import useStartEndAssignment from '../hooks/useStartEndAssignment'; import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; import ZUIFuture from 'zui/ZUIFuture'; import ZUIDateRangePicker from 'zui/ZUIDateRangePicker/ZUIDateRangePicker'; @@ -46,6 +46,10 @@ const CanvassAssignmentLayout: FC = ({ const stats = useCanvassAssignmentStats(orgId, canvassAssId); const state = useCanvassAssignmentStatus(orgId, canvassAssId); + const { startAssignment, endAssignment } = useStartEndAssignment( + orgId, + canvassAssId + ); const canvassers = getCanvassers(sessions); @@ -59,25 +63,11 @@ const CanvassAssignmentLayout: FC = ({ - updateCanvassAssignment({ - end_date: dayjs().format('YYYY-MM-DD'), - }) - } - variant="outlined" - > + ) : ( - ) From 018f9b48cc02073e72b6ae750bcef411eddc94b7 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:23:31 +0200 Subject: [PATCH 7/8] rename editor to outcomes --- .../layouts/CanvassAssignmentLayout.tsx | 2 +- .../[canvassAssId]/{editor.tsx => outcomes.tsx} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/{editor.tsx => outcomes.tsx} (97%) diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index 5d038f84c..547ed8ea4 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -112,7 +112,7 @@ const CanvassAssignmentLayout: FC = ({ { href: '/', label: 'Overview' }, { href: '/plan', label: 'Plan' }, { href: '/canvassers', label: 'Canvassers' }, - { href: '/editor', label: 'Editor' }, + { href: '/outcomes', label: 'Outcomes' }, ]} title={ { }; }, scaffoldOptions); -interface CanvassAssignmentEditorProps { +interface CanvassAssignmentOutcomesProps { orgId: string; canvassAssId: string; } -const CanvassAssignmentEditorPage: PageWithLayout< - CanvassAssignmentEditorProps +const CanvassAssignmentOutcomesPage: PageWithLayout< + CanvassAssignmentOutcomesProps > = ({ orgId, canvassAssId }) => { const updateCanvassAssignment = useCanvassAssignmentMutations( parseInt(orgId), @@ -276,10 +276,10 @@ const CanvassAssignmentEditorPage: PageWithLayout< ); }; -CanvassAssignmentEditorPage.getLayout = function getLayout(page) { +CanvassAssignmentOutcomesPage.getLayout = function getLayout(page) { return ( {page} ); }; -export default CanvassAssignmentEditorPage; +export default CanvassAssignmentOutcomesPage; From e41a4770e31dc8fe58d2baad8b36d2541573fb76 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:00:15 +0200 Subject: [PATCH 8/8] wrap canvassers table in a Card, create new MapControls component to reuse it, implement component --- .../areas/components/AreasMap/index.tsx | 130 ++++++------------ .../components/MapControls.tsx | 79 +++++++++++ .../canvassAssignments/components/PlanMap.tsx | 66 ++++++--- .../[canvassAssId]/canvassers.tsx | 31 +++-- 4 files changed, 187 insertions(+), 119 deletions(-) create mode 100644 src/features/canvassAssignments/components/MapControls.tsx diff --git a/src/features/areas/components/AreasMap/index.tsx b/src/features/areas/components/AreasMap/index.tsx index 8fbe078be..827af1836 100644 --- a/src/features/areas/components/AreasMap/index.tsx +++ b/src/features/areas/components/AreasMap/index.tsx @@ -1,21 +1,12 @@ import 'leaflet/dist/leaflet.css'; import { FC, useEffect, useRef, useState } from 'react'; import { MapContainer } from 'react-leaflet'; -import { - Add, - Close, - Create, - GpsFixed, - Home, - Remove, - Save, -} from '@mui/icons-material'; +import { Close, Create, Save } from '@mui/icons-material'; import { Autocomplete, Box, Button, ButtonGroup, - CircularProgress, Divider, MenuItem, TextField, @@ -32,6 +23,7 @@ import AreaOverlay from '../AreaOverlay'; import MapRenderer from './MapRenderer'; import AreaFilterProvider from '../AreaFilters/AreaFilterContext'; import AreaFilterButton from '../AreaFilters/AreaFilterButton'; +import MapControls from 'features/canvassAssignments/components/MapControls'; interface MapProps { areas: ZetkinArea[]; @@ -108,6 +100,41 @@ const Map: FC = ({ areas }) => { const filteredAreas = filterAreas(areas, filterText); + const zoomIn = () => { + mapRef.current?.zoomIn(); + }; + + const zoomOut = () => { + mapRef.current?.zoomOut(); + }; + + const fitBounds = () => { + const map = mapRef.current; + if (map) { + if (areas.length) { + const totalBounds = latLngBounds( + areas[0].points.map((p) => objToLatLng(p)) + ); + + areas.forEach((area) => { + const areaBounds = latLngBounds( + area.points.map((p) => objToLatLng(p)) + ); + totalBounds.extend(areaBounds); + }); + + if (totalBounds) { + map.fitBounds(totalBounds, { animate: true }); + } + } + } + }; + + const onLocate = () => ({ + locating, + setLocating, + }); + return ( = ({ areas }) => { )} - - - - - - - - + {selectedArea && ( ; + onFitBounds: () => void; + onLocate: () => { locating: boolean; setLocating: (value: boolean) => void }; + onZoomIn: () => void; + onZoomOut: () => void; +}; + +const MapControls: React.FC = ({ + mapRef, + onFitBounds, + onLocate, + onZoomIn, + onZoomOut, +}) => { + const { locating, setLocating } = onLocate(); + + return ( + + + + + + + + + ); +}; + +export default MapControls; diff --git a/src/features/canvassAssignments/components/PlanMap.tsx b/src/features/canvassAssignments/components/PlanMap.tsx index 16ba20da5..f19c3dc29 100644 --- a/src/features/canvassAssignments/components/PlanMap.tsx +++ b/src/features/canvassAssignments/components/PlanMap.tsx @@ -1,22 +1,15 @@ import { FC, useRef, useState } from 'react'; -import { Map as MapType } from 'leaflet'; +import { Map as MapType, latLngBounds } from 'leaflet'; import { MapContainer } from 'react-leaflet'; -import { - Autocomplete, - Box, - Button, - ButtonGroup, - Chip, - MenuItem, - TextField, -} from '@mui/material'; -import { Add, Remove } from '@mui/icons-material'; +import { Autocomplete, Box, Chip, MenuItem, TextField } from '@mui/material'; import { ZetkinArea } from '../../areas/types'; import PlanMapRenderer from './PlanMapRenderer'; import AreaPlanningOverlay from '../../areas/components/AreaPlanningOverlay'; import { ZetkinPerson } from 'utils/types/zetkin'; import { ZetkinCanvassSession } from '../types'; +import objToLatLng from 'features/areas/utils/objToLatLng'; +import MapControls from './MapControls'; type PlanMapProps = { areas: ZetkinArea[]; @@ -31,6 +24,7 @@ const PlanMap: FC = ({ }) => { const [filterAssigned, setFilterAssigned] = useState(false); const [filterUnassigned, setFilterUnassigned] = useState(false); + const [locating, setLocating] = useState(false); const mapRef = useRef(null); @@ -56,6 +50,41 @@ const PlanMap: FC = ({ }); } + const zoomIn = () => { + mapRef.current?.zoomIn(); + }; + + const zoomOut = () => { + mapRef.current?.zoomOut(); + }; + + const fitBounds = () => { + const map = mapRef.current; + if (map) { + if (areas.length) { + const totalBounds = latLngBounds( + areas[0].points.map((p) => objToLatLng(p)) + ); + + areas.forEach((area) => { + const areaBounds = latLngBounds( + area.points.map((p) => objToLatLng(p)) + ); + totalBounds.extend(areaBounds); + }); + + if (totalBounds) { + map.fitBounds(totalBounds, { animate: true }); + } + } + } + }; + + const onLocate = () => ({ + locating, + setLocating, + }); + return ( = ({ }} > - - - - + = ({ ]; return ( - + + + ); };