diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index aaa8395f6..bf54cfdd8 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,11 +120,30 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { } } } + type UpdateFieldsType = Partial< + Pick + >; - await CanvassAssignmentModel.updateOne( - { _id: params.canvassAssId }, - { title } - ); + const updateFields: UpdateFieldsType = {}; + + if (title !== null) { + updateFields.title = title; + } + + if (Object.prototype.hasOwnProperty.call(payload, 'start_date')) { + updateFields.start_date = start_date; + } + + if (Object.prototype.hasOwnProperty.call(payload, 'end_date')) { + updateFields.end_date = end_date; + } + + if (Object.keys(updateFields).length > 0) { + await CanvassAssignmentModel.updateOne( + { _id: params.canvassAssId }, + { $set: updateFields } + ); + } const model = await CanvassAssignmentModel.findById( params.canvassAssId ).populate('metrics'); @@ -134,6 +155,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 +165,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/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 && ( = ({ 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/components/AssignmentStatusChip.tsx b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx new file mode 100644 index 000000000..8c62b85d3 --- /dev/null +++ b/src/features/canvassAssignments/components/AssignmentStatusChip.tsx @@ -0,0 +1,59 @@ +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, + [CanvassAssignmentState.DRAFT]: classes.draft, + }; + + const colorClassName = classMap[state]; + + return ( + + {capitalizeFirstLetter(state)} + + ); +}; + +export default AssignmentStatusChip; diff --git a/src/features/canvassAssignments/components/MapControls.tsx b/src/features/canvassAssignments/components/MapControls.tsx new file mode 100644 index 000000000..4fd6460c5 --- /dev/null +++ b/src/features/canvassAssignments/components/MapControls.tsx @@ -0,0 +1,79 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import { Map } from 'leaflet'; +import React, { MutableRefObject } from 'react'; +import { Box, Button, ButtonGroup } from '@mui/material'; +import { Add, Remove, GpsFixed, Home } from '@mui/icons-material'; + +type MapControlsProps = { + mapRef: MutableRefObject; + 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 ( = ({ }} > - - - - + { + 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 c35e1dd53..547ed8ea4 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -1,10 +1,23 @@ -import { FC, ReactNode } from 'react'; +import { Box } from '@mui/system'; 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'; -import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; 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'; +import useCanvassAssignmentStatus, { + CanvassAssignmentState, +} from '../hooks/useCanvassAssignmentStatus'; type CanvassAssignmentLayoutProps = { campId: number; @@ -26,6 +39,20 @@ 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 state = useCanvassAssignmentStatus(orgId, canvassAssId); + const { startAssignment, endAssignment } = useStartEndAssignment( + orgId, + canvassAssId + ); + + const canvassers = getCanvassers(sessions); + const isPlanTab = path.endsWith('/plan'); if (!canvassAssignment) { @@ -34,14 +61,58 @@ const CanvassAssignmentLayout: FC = ({ return ( + {'End Assignment'} + + ) : ( + + ) + } baseHref={`/organize/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}`} + belowActionButtons={ + { + updateCanvassAssignment({ + end_date: endDate, + start_date: startDate, + }); + }} + startDate={canvassAssignment.start_date || null} + /> + } defaultTab="/" fixedHeight={isPlanTab} + subtitle={ + + + + + + {(data) => ( + + + {data.num_areas} Area(s) + + )} + + + + + {canvassers.length} Canvasser(s) + + + + } tabs={[ { href: '/', label: 'Overview' }, { href: '/plan', label: 'Plan' }, { href: '/canvassers', label: 'Canvassers' }, - { href: '/editor', label: 'Editor' }, + { href: '/outcomes', label: 'Outcomes' }, ]} title={ & { _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 814def005..7fb25eb68 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; @@ -13,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; }; 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..35b279c7a 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx @@ -1,15 +1,16 @@ +import { Card } from '@mui/material'; import { GetServerSideProps } from 'next'; 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 +29,6 @@ type Props = { orgId: string; }; -type CanvasserInfo = { - id: number; - person: ZetkinPerson; - sessions: ZetkinCanvassSession[]; -}; - const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, @@ -44,21 +39,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[] = [ { @@ -94,20 +75,22 @@ const CanvassAssignmentPage: PageWithLayout = ({ ]; return ( - + + + ); }; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx similarity index 97% rename from src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx rename to src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx index 2e95a8c6d..b5bf558c9 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx @@ -34,13 +34,13 @@ export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { }; }, 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;