diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts index 1adfd98b4..d543badc3 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { CanvassAssigneeModel } from 'features/areas/models'; +import { CanvassAssigneeModel } from 'features/canvassAssignments/models'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/route.ts index 0f2446f80..5f4bae82a 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/assignees/route.ts @@ -1,9 +1,9 @@ import mongoose from 'mongoose'; import { NextRequest } from 'next/server'; +import { ZetkinCanvassAssignee } from 'features/canvassAssignments/types'; +import { CanvassAssigneeModel } from 'features/canvassAssignments/models'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { CanvassAssigneeModel } from 'features/areas/models'; -import { ZetkinCanvassAssignee } from 'features/areas/types'; type RouteMeta = { params: { diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index 073731dca..aaa8395f6 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -2,8 +2,11 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { CanvassAssignmentModel } from 'features/areas/models'; -import { ZetkinCanvassAssignment } from 'features/areas/types'; +import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; +import { + ZetkinCanvassAssignment, + ZetkinMetric, +} from 'features/canvassAssignments/types'; type RouteMeta = { params: { @@ -34,6 +37,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const canvassAssignment: ZetkinCanvassAssignment = { campaign: { id: canvassAssignmentModel.campId }, id: canvassAssignmentModel._id.toString(), + metrics: (canvassAssignmentModel.metrics || []).map((metric) => ({ + definesDone: metric.definesDone || false, + description: metric.description || '', + id: metric._id, + kind: metric.kind, + question: metric.question, + })), organization: { id: orgId }, title: canvassAssignmentModel.title, }; @@ -54,14 +64,68 @@ 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 model = await CanvassAssignmentModel.findOneAndUpdate( + if (newMetrics) { + // Find existing metrics to remove + const assignment = await CanvassAssignmentModel.findById( + params.canvassAssId + ).select('metrics'); + + if (!assignment) { + return new NextResponse(null, { status: 404 }); + } + + const existingMetricsIds = assignment.metrics.map((metric) => + metric._id.toString() + ); + + // Identify metrics to be deleted + const metricsToDelete = existingMetricsIds.filter( + (id) => !newMetrics.some((metric: ZetkinMetric) => metric.id === id) + ); + + // Remove metrics that are no longer included + if (metricsToDelete.length > 0) { + await CanvassAssignmentModel.updateOne( + { _id: params.canvassAssId }, + { $pull: { metrics: { _id: { $in: metricsToDelete } } } } + ); + } + + for (const metric of newMetrics) { + if (metric.id) { + // If the metric has an ID, update it + await CanvassAssignmentModel.updateOne( + { _id: params.canvassAssId, 'metrics._id': metric.id }, + { + $set: { + 'metrics.$.definesDone': metric.definesDone, + 'metrics.$.description': metric.description, + 'metrics.$.kind': metric.kind, + 'metrics.$.question': metric.question, + }, + } + ); + } else { + // If no ID exists, push it as a new metric + await CanvassAssignmentModel.updateOne( + { _id: params.canvassAssId }, + { + $push: { metrics: metric }, + } + ); + } + } + } + + await CanvassAssignmentModel.updateOne( { _id: params.canvassAssId }, - { - title: payload.title, - }, - { new: true } + { title } ); + const model = await CanvassAssignmentModel.findById( + params.canvassAssId + ).populate('metrics'); if (!model) { return new NextResponse(null, { status: 404 }); @@ -71,6 +135,13 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { data: { campaign: { id: model.campId }, id: model._id.toString(), + metrics: (model.metrics || []).map((metric) => ({ + definesDone: metric.definesDone || false, + description: metric.description || '', + id: metric._id, + kind: metric.kind, + question: metric.question, + })), organization: { id: orgId }, title: model.title, }, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts index 3fd0e1ebe..e7796c87a 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -1,10 +1,11 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { AreaModel, CanvassAssignmentModel } from 'features/areas/models'; -import { ZetkinCanvassSession } from 'features/areas/types'; +import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; +import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; import { ZetkinPerson } from 'utils/types/zetkin'; +import { AreaModel } from 'features/areas/models'; type RouteMeta = { params: { diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts index 62104fad8..59948ba59 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts @@ -1,21 +1,22 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { ZetkinPerson } from 'utils/types/zetkin'; +import isPointInsidePolygon from 'features/canvassAssignments/utils/isPointInsidePolygon'; import { - AreaModel, CanvassAssignmentModel, PlaceModel, -} from 'features/areas/models'; +} from 'features/canvassAssignments/models'; import { Household, Visit, - ZetkinArea, + ZetkinCanvassAssignmentStats, ZetkinCanvassSession, ZetkinPlace, -} from 'features/areas/types'; -import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { ZetkinPerson } from 'utils/types/zetkin'; -import isPointInsidePolygon from 'features/areas/utils/isPointInsidePolygon'; +} from 'features/canvassAssignments/types'; +import { AreaModel } from 'features/areas/models'; +import { ZetkinArea } from 'features/areas/types'; type RouteMeta = { params: { @@ -35,40 +36,40 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { await mongoose.connect(process.env.MONGODB_URL || ''); //Find all sessions of the assignment - const model = await CanvassAssignmentModel.findOne({ + const assignmentModel = await CanvassAssignmentModel.findOne({ _id: params.canvassAssId, }); - if (!model) { + if (!assignmentModel) { return new NextResponse(null, { status: 404 }); } const sessions: ZetkinCanvassSession[] = []; - for await (const sessionData of model.sessions) { + for await (const sessionData of assignmentModel.sessions) { const person = await apiClient.get( `/api/orgs/${orgId}/people/${sessionData.personId}` ); - const area = await AreaModel.findOne({ + const areaModel = await AreaModel.findOne({ _id: sessionData.areaId, }); - if (area && person) { + if (areaModel && person) { sessions.push({ area: { - description: area.description, - id: area._id.toString(), + description: areaModel.description, + id: areaModel._id.toString(), organization: { id: orgId, }, - points: area.points, + points: areaModel.points, tags: [], //TODO: Is this really neccessary here? - title: area.title, + title: areaModel.title, }, assignee: person, assignment: { - id: model._id.toString(), - title: model.title, + id: assignmentModel._id.toString(), + title: assignmentModel.title, }, }); } @@ -90,7 +91,6 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, })); type PlaceWithAreaId = ZetkinPlace & { areaId: ZetkinArea['id'] }; @@ -116,10 +116,55 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { ]; const visitsInAreas: Visit[] = []; + const successfulVisitsInAreas: Visit[] = []; const visitedPlacesInAreas: string[] = []; const visitedAreas: string[] = []; const householdsInAreas: Household[] = []; + const configuredMetrics = assignmentModel.metrics; + const idOfMetricThatDefinesDone = configuredMetrics.find( + (metric) => metric.definesDone + )?._id; + const accumulatedMetrics: ZetkinCanvassAssignmentStats['metrics'] = + configuredMetrics.map((metric) => ({ + metric: { + definesDone: metric.definesDone, + description: metric.description, + id: metric._id, + kind: metric.kind, + question: metric.question, + }, + values: metric.kind == 'boolean' ? [0] : [0, 0, 0, 0, 0], + })); + + allPlaces.forEach((place) => { + place.households.forEach((household) => { + household.visits.forEach((visit) => { + if (visit.canvassAssId == params.canvassAssId) { + visit.responses.forEach((response) => { + const configuredMetric = configuredMetrics.find( + (candidate) => candidate._id == response.metricId + ); + + const accumulatedMetric = accumulatedMetrics.find( + (accum) => accum.metric.id == response.metricId + ); + + if (accumulatedMetric && configuredMetric) { + if (response.response == 'yes') { + accumulatedMetric.values[0]++; + } else if (configuredMetric.kind == 'scale5') { + const rating = parseInt(response.response); + const index = rating - 1; + accumulatedMetric.values[index]++; + } + } + }); + } + }); + }); + }); + uniquePlacesInAreas.forEach((place) => { householdsInAreas.push(...place.households); place.households.forEach((household) => { @@ -128,6 +173,14 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { visitedAreas.push(place.areaId); visitedPlacesInAreas.push(place.id); visitsInAreas.push(visit); + + visit.responses.forEach((response) => { + if (response.metricId == idOfMetricThatDefinesDone) { + if (response.response == 'yes') { + successfulVisitsInAreas.push(visit); + } + } + }); } }); }); @@ -156,9 +209,11 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { return Response.json({ data: { + metrics: accumulatedMetrics, num_areas: uniqueAreas.length, num_households: householdsInAreas.length, num_places: uniquePlacesInAreas.length, + num_successful_visited_households: successfulVisitsInAreas.length, num_visited_areas: Array.from(new Set(visitedAreas)).length, num_visited_households: visitsInAreas.length, num_visited_households_outside_areas: visitsOutsideAreas.length, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts index 63a87d709..2171ae1f6 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { CanvassAssignmentModel } from 'features/areas/models'; +import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; type RouteMeta = { params: { @@ -28,6 +28,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { id: assignment.campId, }, id: assignment._id.toString(), + metrics: (assignment.metrics || []).map((metric) => ({ + definesDone: metric.definesDone || false, + description: metric.description || '', + id: metric._id, + kind: metric.kind, + question: metric.question, + })), organization: { id: orgId, }, @@ -52,6 +59,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const model = new CanvassAssignmentModel({ campId: payload.campaign_id, + metrics: payload.metrics || [], orgId: orgId, title: payload.title, }); @@ -62,6 +70,13 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { data: { campaign: { id: model.campId }, id: model._id.toString(), + metrics: model.metrics.map((metric) => ({ + definesDone: metric.definesDone || false, + description: metric.description || '', + id: metric._id, + kind: metric.kind, + question: metric.question, + })), organization: { id: orgId }, title: model.title, }, diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/ratings/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/ratings/route.ts deleted file mode 100644 index f05f1dd9d..000000000 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/ratings/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import mongoose from 'mongoose'; -import { NextRequest, NextResponse } from 'next/server'; - -import { PlaceModel } from 'features/areas/models'; -import asOrgAuthorized from 'utils/api/asOrgAuthorized'; - -type RouteMeta = { - params: { - householdId: string; - orgId: string; - placeId: string; - }; -}; - -export async function POST(request: NextRequest, { params }: RouteMeta) { - return asOrgAuthorized( - { - orgId: params.orgId, - request: request, - roles: ['admin'], - }, - async ({ orgId }) => { - await mongoose.connect(process.env.MONGODB_URL || ''); - - const payload = await request.json(); - - const model = await PlaceModel.findOneAndUpdate( - { _id: params.placeId, orgId }, - { - $push: { - 'households.$[elem].ratings': { - id: new mongoose.Types.ObjectId().toString(), - rate: payload.rate, - timestamp: payload.timestamp, - }, - }, - }, - { - arrayFilters: [{ 'elem.id': { $eq: params.householdId } }], - new: true, - } - ); - - if (!model) { - return new NextResponse(null, { status: 404 }); - } - - await model.save(); - - return NextResponse.json({ - data: { - description: model.description, - households: model.households, - id: model._id.toString(), - orgId: orgId, - position: model.position, - title: model.title, - type: model.type, - }, - }); - } - ); -} diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts index c2cabd89a..e4dbf5dbe 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts +++ b/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { PlaceModel } from 'features/areas/models'; +import { PlaceModel } from 'features/canvassAssignments/models'; type RouteMeta = { params: { @@ -47,7 +47,6 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, }, }); } diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts index 2cf131a8d..0b3b03506 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts +++ b/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/areas/models'; +import { PlaceModel } from 'features/canvassAssignments/models'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { @@ -30,8 +30,11 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { $push: { 'households.$[elem].visits': { canvassAssId: payload.canvassAssId, + doorWasOpened: payload.doorWasOpened, id: new mongoose.Types.ObjectId().toString(), - rating: payload.rating, + missionAccomplished: payload.missionAccomplished, + noteToOfficial: payload.noteToOfficial, + responses: payload.responses || [], timestamp: payload.timestamp, }, }, @@ -56,7 +59,6 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, }, }); } diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts index de4f6c43e..6e4601ca5 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts +++ b/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/areas/models'; +import { PlaceModel } from 'features/canvassAssignments/models'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { @@ -57,7 +57,6 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, }, }); } diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts index beaa2bda1..69c1fc067 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts +++ b/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { PlaceModel } from 'features/areas/models'; +import { PlaceModel } from 'features/canvassAssignments/models'; type RouteMeta = { params: { @@ -47,7 +47,6 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, }, }); } diff --git a/src/app/beta/orgs/[orgId]/places/route.ts b/src/app/beta/orgs/[orgId]/places/route.ts index f85daa914..5967ff192 100644 --- a/src/app/beta/orgs/[orgId]/places/route.ts +++ b/src/app/beta/orgs/[orgId]/places/route.ts @@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import mongoose from 'mongoose'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { PlaceModel } from 'features/areas/models'; -import { ZetkinPlace } from 'features/areas/types'; +import { PlaceModel } from 'features/canvassAssignments/models'; +import { ZetkinPlace } from 'features/canvassAssignments/types'; type RouteMeta = { params: { @@ -29,7 +29,6 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, })); return Response.json({ data: places }); @@ -55,7 +54,6 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: payload.position, title: payload.title, - type: payload.type, }); await model.save(); @@ -68,7 +66,6 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { orgId: orgId, position: model.position, title: model.title, - type: model.type, }, }); } diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/canvassassignments/route.ts index a76b7d6b3..c0edec3a4 100644 --- a/src/app/beta/users/me/canvassassignments/route.ts +++ b/src/app/beta/users/me/canvassassignments/route.ts @@ -3,9 +3,10 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import { AreaModel, CanvassAssignmentModel } from 'features/areas/models'; -import { ZetkinCanvassSession } from 'features/areas/types'; +import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; +import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; import { ZetkinMembership, ZetkinPerson } from 'utils/types/zetkin'; +import { AreaModel } from 'features/areas/models'; export async function GET(request: NextRequest) { const headers: IncomingHttpHeaders = {}; diff --git a/src/app/my/canvassassignments/page.tsx b/src/app/my/canvassassignments/page.tsx index 031635108..5fc6e02c1 100644 --- a/src/app/my/canvassassignments/page.tsx +++ b/src/app/my/canvassassignments/page.tsx @@ -2,7 +2,7 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import MyCanvassAssignmentsPage from 'features/areas/components/MyCanvassAssignmentsPage'; +import MyCanvassAssignmentsPage from 'features/canvassAssignments/components/MyCanvassAssignmentsPage'; import { ZetkinOrganization } from 'utils/types/zetkin'; export default async function Page() { diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx index 45d798c2b..bd2a57f3c 100644 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -1,7 +1,7 @@ import 'leaflet/dist/leaflet.css'; import { notFound } from 'next/navigation'; -import AreaPage from 'features/areas/components/AreaPage'; +import PublicAreaPage from 'features/canvassAssignments/components/PublicAreaPage'; import { AREAS, hasFeature } from 'utils/featureFlags'; interface PageProps { @@ -19,5 +19,5 @@ export default function Page({ params }: PageProps) { return notFound(); } - return ; + return ; } diff --git a/src/core/store.ts b/src/core/store.ts index 7458f4839..b0c49058d 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -49,15 +49,17 @@ import tagsSlice, { TagsStoreSlice } from 'features/tags/store'; import tasksSlice, { TasksStoreSlice } from 'features/tasks/store'; import userSlice, { UserStoreSlice } from 'features/user/store'; import viewsSlice, { ViewsStoreSlice } from 'features/views/store'; -import areasSlice, { - AreasStoreSlice, +import areasSlice, { AreasStoreSlice } from 'features/areas/store'; +import canvassAssignmentSlice, { canvassAssignmentCreated, -} from 'features/areas/store'; + CanvassAssignmentsStoreSlice, +} from 'features/canvassAssignments/store'; export interface RootState { areas: AreasStoreSlice; breadcrumbs: BreadcrumbsStoreSlice; callAssignments: CallAssignmentSlice; + canvassAssignments: CanvassAssignmentsStoreSlice; campaigns: CampaignsStoreSlice; duplicates: PotentialDuplicatesStoreSlice; emails: EmailStoreSlice; @@ -83,6 +85,7 @@ const reducer = { breadcrumbs: breadcrumbsSlice.reducer, callAssignments: callAssignmentsSlice.reducer, campaigns: campaignsSlice.reducer, + canvassAssignments: canvassAssignmentSlice.reducer, duplicates: potentialDuplicatesSlice.reducer, emails: emailsSlice.reducer, events: eventsSlice.reducer, diff --git a/src/features/areas/components/AreaFilters/AddFilterButton.tsx b/src/features/areas/components/AreaFilters/AddFilterButton.tsx index a6fa40fa2..17ada2b3e 100644 --- a/src/features/areas/components/AreaFilters/AddFilterButton.tsx +++ b/src/features/areas/components/AreaFilters/AddFilterButton.tsx @@ -6,8 +6,6 @@ import { MenuItem } from '@mui/material'; import { FC, useState } from 'react'; import theme from 'theme'; -import messageIds from 'features/areas/l10n/messageIds'; -import { Msg } from 'core/i18n'; type Props = { items: { @@ -31,7 +29,7 @@ const AddFilterButton: FC = ({ items, open, onToggle }) => { startIcon={} variant="text" > - + Add filter void; @@ -27,7 +25,7 @@ const AreaFilterButton: FC = ({ onToggle }) => { ) } > - + Filter ); }; diff --git a/src/features/areas/components/AreaFilters/index.tsx b/src/features/areas/components/AreaFilters/index.tsx index 85d11dc46..c88a63d06 100644 --- a/src/features/areas/components/AreaFilters/index.tsx +++ b/src/features/areas/components/AreaFilters/index.tsx @@ -4,8 +4,6 @@ import { useTheme } from '@mui/styles'; import { ZetkinArea } from 'features/areas/types'; import { ZetkinTag, ZetkinTagGroup } from 'utils/types/zetkin'; -import { useMessages } from 'core/i18n'; -import messageIds from 'features/areas/l10n/messageIds'; import FilterDropDown from '../FilterDropDown'; import { areaFilterContext } from './AreaFilterContext'; import AddFilterButton from './AddFilterButton'; @@ -24,7 +22,6 @@ const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { activeTagIdsByGroup, setActiveTagIdsByGroup, } = useContext(areaFilterContext); - const messages = useMessages(messageIds); const groupsById = useMemo(() => { const groupsById: Record< @@ -96,11 +93,7 @@ const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { }, }; })} - label={ - info.group - ? info.group.title - : messages.filters.tagsWithoutGroup() - } + label={info.group ? info.group.title : 'Ungrouped tags'} onToggle={(open) => setOpenDropdown(open ? groupId : null)} open={openDropdown == groupId} startIcon={ @@ -141,9 +134,7 @@ const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { return { icon: , - label: item.group - ? messages.filters.tagGroup({ label: item.group.title }) - : messages.filters.tagsWithoutGroup(), + label: item.group ? item.group.title : 'Ungrouped tags', onClick: () => { if (selected) { setActiveGroupIds(activeGroupIds.filter((id) => groupId != id)); diff --git a/src/features/areas/components/AreaOverlay/TagsSection.tsx b/src/features/areas/components/AreaOverlay/TagsSection.tsx index e9798c2a6..672e251e5 100644 --- a/src/features/areas/components/AreaOverlay/TagsSection.tsx +++ b/src/features/areas/components/AreaOverlay/TagsSection.tsx @@ -1,10 +1,8 @@ import { FC, useState } from 'react'; import { Box, Typography } from '@mui/material'; -import { Msg } from 'core/i18n'; import useAreaTagging from 'features/areas/hooks/useAreaTagging'; import useAreaTags from 'features/areas/hooks/useAreaTags'; -import messageIds from 'features/areas/l10n/messageIds'; import { ZetkinArea } from 'features/areas/types'; import TagManager from 'features/tags/components/TagManager'; import GroupToggle from 'features/tags/components/TagManager/components/GroupToggle'; @@ -33,9 +31,7 @@ const TagsSection: FC = ({ area }) => { mb={2} minHeight={38} > - - - + Area tags = ({ area.organization.id, area.id ); - const messages = useMessages(messageIds); const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); const handleDescriptionTextAreaRef = useCallback( @@ -116,7 +113,7 @@ const AreaOverlay: FC = ({ )} renderPreview={() => ( - {area.title || messages.empty.title()} + {area.title || 'Untitled area'} )} value={area.title || ''} @@ -165,7 +162,7 @@ const AreaOverlay: FC = ({ > {area.description?.trim().length ? area.description - : messages.empty.description()} + : 'Empty description'} )} @@ -189,22 +186,22 @@ const AreaOverlay: FC = ({ }} variant="contained" > - + Save )} {!editing && ( <> { showConfirmDialog({ onSubmit: () => { diff --git a/src/features/areas/components/AreaPlanningOverlay.tsx b/src/features/areas/components/AreaPlanningOverlay.tsx index b505184fb..ca09b8c84 100644 --- a/src/features/areas/components/AreaPlanningOverlay.tsx +++ b/src/features/areas/components/AreaPlanningOverlay.tsx @@ -3,8 +3,6 @@ import { Close } from '@mui/icons-material'; import { Box, Divider, Paper, Typography } from '@mui/material'; import { ZetkinArea } from '../types'; -import { Msg, useMessages } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; import { ZetkinPerson } from 'utils/types/zetkin'; import ZUIPerson from 'zui/ZUIPerson'; import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; @@ -22,8 +20,6 @@ const AreaPlanningOverlay: FC = ({ onAddAssignee, onClose, }) => { - const messages = useMessages(messageIds); - return ( = ({ > - - {area.title || messages.empty.title()} - + {area.title || 'Untitled area'} { @@ -61,23 +55,21 @@ const AreaPlanningOverlay: FC = ({ > {area.description?.trim().length ? area.description - : messages.empty.description()} + : 'Empty description'} - - - + Assignees {!assignees.length && ( - + No assignees )} {assignees.map((assignee) => ( @@ -90,9 +82,7 @@ const AreaPlanningOverlay: FC = ({ ))} - - - + Add assignee = ({ areas }) => { - const messages = useMessages(messageIds); const mapRef = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); const [selectedId, setSelectedId] = useState(''); @@ -92,8 +89,8 @@ const Map: FC = ({ areas }) => { inputValue.length == 0 ? areas.concat() : areas.filter((area) => { - const areaTitle = area.title || messages.empty.title(); - const areaDesc = area.description || messages.empty.description(); + const areaTitle = area.title || 'Untitled area'; + const areaDesc = area.description || 'Empty description'; return ( areaTitle.toLowerCase().includes(inputValue) || @@ -131,7 +128,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - + Draw )} {drawingPoints && ( @@ -141,7 +138,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - + Cancel )} {drawingPoints && drawingPoints.length > 2 && ( @@ -151,7 +148,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - + Save )} @@ -188,9 +185,7 @@ const Map: FC = ({ areas }) => { /> )} renderOption={(props, area) => ( - - {area.title || messages.empty.title()} - + {area.title || 'Untitled area'} )} value={null} /> diff --git a/src/features/areas/components/PlaceDialog.tsx b/src/features/areas/components/PlaceDialog.tsx deleted file mode 100644 index 3cd2c0dc1..000000000 --- a/src/features/areas/components/PlaceDialog.tsx +++ /dev/null @@ -1,509 +0,0 @@ -import { - ArrowBackIos, - Check, - Edit, - ThumbDown, - ThumbUp, -} from '@mui/icons-material'; -import { FC, useState } from 'react'; -import { - Box, - Button, - ButtonGroup, - CircularProgress, - Dialog, - Divider, - FormControl, - InputLabel, - MenuItem, - Select, - SelectChangeEvent, - TextField, - Typography, -} from '@mui/material'; - -import { isWithinLast24Hours } from '../utils/isWithinLast24Hours'; -import messageIds from '../l10n/messageIds'; -import usePlaceMutations from '../hooks/usePlaceMutations'; -import { ZetkinPlace } from '../types'; -import ZUIDateTime from 'zui/ZUIDateTime'; -import { Msg, useMessages } from 'core/i18n'; - -type PlaceDialogProps = { - canvassAssId: string | null; - dialogStep: 'place' | 'edit' | 'household'; - onClose: () => void; - onEdit: () => void; - onSelectHousehold: () => void; - onUpdateDone: () => void; - open: boolean; - orgId: number; - place: ZetkinPlace; -}; - -const PlaceDialog: FC = ({ - canvassAssId, - dialogStep, - onClose, - onEdit, - onUpdateDone, - onSelectHousehold, - open, - orgId, - place, -}) => { - const { - addHousehold, - addVisit, - updateHousehold, - updatePlace, - isAddVisitLoading, - } = usePlaceMutations(orgId, place.id); - const messages = useMessages(messageIds); - - const [selectedHouseholdId, setSelectedHouseholdId] = useState( - null - ); - const [description, setDescription] = useState( - place.description ?? '' - ); - const [title, setTitle] = useState(place.title ?? ''); - const [type, setType] = useState<'address' | 'misc'>(place.type); - const [editingHouseholdTitle, setEditingHouseholdTitle] = useState(false); - const [householdTitle, setHousholdTitle] = useState(''); - - const handleChange = (event: SelectChangeEvent) => { - setType(event.target.value as 'address' | 'misc'); - }; - - const selectedHousehold = place.households.find( - (household) => household.id == selectedHouseholdId - ); - - const sortedVisits = - selectedHousehold?.visits.toSorted((a, b) => { - const dateA = new Date(a.timestamp); - const dateB = new Date(b.timestamp); - if (dateA > dateB) { - return -1; - } else if (dateB > dateA) { - return 1; - } else { - return 0; - } - }) || []; - - const nothingHasBeenEdited = - dialogStep == 'edit' && - title == place.title && - type == place.type && - (description == place.description || (!description && !place.description)); - - const saveButtonDisabled = nothingHasBeenEdited; - - const getBackButtonMessage = () => { - if (dialogStep == 'edit') { - if (nothingHasBeenEdited) { - return messageIds.place.backButton; - } else { - return messageIds.place.cancelButton; - } - } else if (dialogStep == 'household') { - return messageIds.place.closeButton; - } else { - return messageIds.place.closeButton; - } - }; - - return ( - - - - {dialogStep !== 'edit' && ( - <> - {dialogStep !== 'household' && ( - - {place?.title || } - - )} - {selectedHousehold && dialogStep == 'household' && ( - - { - onUpdateDone(); - }} - sx={{ marginRight: 2 }} - /> - {selectedHousehold.title || ( - - )} - - )} - - {selectedHousehold && dialogStep == 'household' && ( - - )} - {dialogStep === 'place' && ( - - )} - - )} - {dialogStep === 'edit' && ( - - - - )} - - - - {dialogStep === 'edit' && ( - - setTitle(ev.target.value)} - /> - - - - - - - setDescription(ev.target.value)} - rows={5} - /> - - )} - {place && dialogStep == 'place' && ( - - - - - - - {place.description || ( - - )} - - - - - - - - {place.households.map((household) => { - const visitedRecently = isWithinLast24Hours( - household.visits.map((t) => t.timestamp) - ); - const mostRecentVisit = household.visits.toSorted( - (a, b) => { - const dateA = new Date(a.timestamp); - const dateB = new Date(b.timestamp); - if (dateA > dateB) { - return -1; - } else if (dateB > dateA) { - return 1; - } else { - return 0; - } - } - )[0]; - - return ( - { - setSelectedHouseholdId(household.id); - onSelectHousehold(); - }} - width="100%" - > - - {household.title || ( - - )} - - {visitedRecently ? ( - - {mostRecentVisit.rating && ( - - {mostRecentVisit.rating == 'good' ? ( - - ) : ( - - )} - - )} - - - ) : ( - '' - )} - - ); - })} - - - - )} - {selectedHousehold && dialogStep == 'household' && ( - - - {editingHouseholdTitle && ( - - setHousholdTitle(ev.target.value)} - placeholder="Household title" - value={householdTitle} - /> - - - - )} - - - - t.timestamp) - )} - fullWidth - sx={{ alignSelf: 'center', display: 'flex' }} - variant="outlined" - > - {!isAddVisitLoading && ( - <> - - - - )} - {isAddVisitLoading && ( - - )} - - - - - - - {sortedVisits.length} - - - - {sortedVisits.length == 0 && ( - - )} - {sortedVisits.map((visit) => ( - - {messages.place.visitLog()} - - - {visit.rating && ( - - {visit.rating === 'good' ? ( - - ) : ( - - )} - - )} - - - ))} - - - - )} - - - {dialogStep === 'place' && ( - - )} - - {dialogStep == 'edit' && ( - - )} - - - - ); -}; - -export default PlaceDialog; diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts deleted file mode 100644 index 9a3c33a43..000000000 --- a/src/features/areas/l10n/messageIds.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { m, makeMessages } from 'core/i18n'; - -export default makeMessages('feat.areas', { - addNewPlaceButton: m('Add new place'), - canvassAssignment: { - addAssignee: m('Add assignee'), - assigneesTitle: m('Assignees'), - canvassers: { - areasColumn: m('Areas'), - nameColumn: m('Name'), - }, - canvassing: { - goToMapButton: m('Go to map'), - title: m('Canvassing'), - }, - empty: { - title: m('Untitled canvass assignment'), - }, - overview: { - areas: { - defineButton: m('Plan now'), - editButton: m('Edit plan'), - subtitle: m('This assignment has not been planned yet.'), - title: m('Areas'), - }, - }, - planFilters: { - assigned: m('Assigned'), - unassigned: m('Unassigned'), - }, - tabs: { - canvassers: m('Canvassers'), - overview: m('Overview'), - plan: m('Plan'), - }, - }, - empty: { - description: m('Empty description'), - title: m('Untitled area'), - }, - filters: { - addFilterButton: m('Add filter'), - filterButton: m('Filters'), - tagGroup: m<{ label: string }>('{label} (tag group)'), - tagsWithoutGroup: m('Tags without group'), - }, - overlay: { - buttons: { - cancel: m('Cancel'), - delete: m('Delete'), - edit: m('Edit'), - save: m('Save'), - }, - tags: { - header: m('Tags'), - }, - }, - place: { - activityHeader: m('Activity'), - addHouseholdButton: m('Add household'), - backButton: m('Back'), - badRatingLog: m('Bad rating'), - cancelButton: m('Cancel'), - closeButton: m('Close'), - description: m('Description'), - editButton: m('Edit'), - editDescription: m('Place description'), - editPlace: m<{ placeName: string }>('Edit {placeName}'), - editTitle: m('Place title'), - empty: { - description: m('Empty description'), - title: m('Untitled place'), - }, - goodRatingLog: m('Good rating'), - household: { - empty: { - title: m('Untitled household'), - }, - }, - householdsHeader: m<{ numberOfHouseholds: number }>( - '{numberOfHouseholds, plural, =0 {No households} =1 {Household 1} other {Households #}}' - ), - logActivityButton: m('Log activity'), - logActivityHeader: m<{ title: string }>('Log activity at {title}'), - logList: m('Log'), - noActivity: m('No visits have been recorded at this place.'), - notePlaceholder: m('Note'), - saveButton: m('Save'), - selectType: m('Place type'), - visitButton: m('Log visit'), - visitLog: m('Visited'), - visitedButton: m('Visited'), - }, - placeCard: { - address: m('Address'), - cancel: m('Cancel'), - createPlace: m('Create place'), - inputLabel: m('Type of place'), - misc: m('Misc'), - placeholderAddress: m('Enter address here'), - placeholderTitle: m('Enter title here'), - }, - planOverlay: { - addAssignee: m('Add assignee'), - assignees: m('Assignees'), - noAssignees: m('No assignees'), - }, - tools: { - cancel: m('Cancel'), - draw: m('Draw'), - save: m('Save'), - }, - viewPlaceButton: m('View place'), -}); diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index e61419479..87fc31607 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -1,11 +1,6 @@ import mongoose from 'mongoose'; -import { - Household, - ZetkinArea, - ZetkinCanvassAssignee, - ZetkinPlace, -} from './types'; +import { ZetkinArea } from './types'; type ZetkinAreaModelType = { description: string | null; @@ -15,28 +10,6 @@ type ZetkinAreaModelType = { title: string | null; }; -type ZetkinPlaceModelType = { - description: string | null; - households: Household[]; - orgId: number; - position: ZetkinPlace['position']; - title: string | null; - type: 'address' | 'misc'; -}; - -type ZetkinCanvassAssignmentModelType = { - campId: number; - id: number; - orgId: number; - sessions: { - areaId: string; - personId: number; - }[]; - title: string | null; -}; - -type ZetkinCanvassAssigneeModelType = ZetkinCanvassAssignee; - const areaSchema = new mongoose.Schema({ description: String, orgId: { required: true, type: Number }, @@ -45,67 +18,6 @@ const areaSchema = new mongoose.Schema({ title: String, }); -const placeSchema = new mongoose.Schema({ - description: String, - households: [ - { - _id: false, - id: String, - title: String, - visits: [ - { - _id: false, - canvassAssId: String, - id: String, - rating: String, - timestamp: String, - }, - ], - }, - ], - orgId: { required: true, type: Number }, - position: Object, - title: String, - type: String, -}); - -const canvassAssignmentSchema = - new mongoose.Schema({ - campId: Number, - orgId: { required: true, type: Number }, - sessions: [ - { - areaId: String, - personId: Number, - }, - ], - title: String, - }); - -const canvassAssigneeSchema = - new mongoose.Schema({ - canvassAssId: String, - id: { required: true, type: Number }, - }); - export const AreaModel: mongoose.Model = mongoose.models.Area || mongoose.model('Area', areaSchema); - -export const PlaceModel: mongoose.Model = - mongoose.models.Place || - mongoose.model('Place', placeSchema); - -export const CanvassAssignmentModel: mongoose.Model = - mongoose.models.CanvassAssignment || - mongoose.model( - 'CanvassAssignment', - canvassAssignmentSchema - ); - -export const CanvassAssigneeModel: mongoose.Model = - mongoose.models.CanvassAssignee || - mongoose.model( - 'CanvassAssignee', - canvassAssigneeSchema - ); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index d7c637e87..c17504226 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -2,49 +2,20 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { findOrAddItem, - RemoteItem, remoteItem, remoteList, RemoteList, } from 'utils/storeUtils'; -import { - ZetkinCanvassAssignmentStats, - ZetkinArea, - ZetkinCanvassAssignee, - ZetkinCanvassAssignment, - ZetkinCanvassSession, - ZetkinPlace, -} from './types'; +import { ZetkinArea } from './types'; import { ZetkinTag } from 'utils/types/zetkin'; export interface AreasStoreSlice { areaList: RemoteList; - canvassAssignmentList: RemoteList; - sessionsByAssignmentId: Record< - string, - RemoteList - >; - assigneesByCanvassAssignmentId: Record< - string, - RemoteList - >; - mySessionsList: RemoteList; - placeList: RemoteList; - statsByCanvassAssId: Record< - string, - RemoteItem - >; tagsByAreaId: Record>; } const initialState: AreasStoreSlice = { areaList: remoteList(), - assigneesByCanvassAssignmentId: {}, - canvassAssignmentList: remoteList(), - mySessionsList: remoteList(), - placeList: remoteList(), - sessionsByAssignmentId: {}, - statsByCanvassAssId: {}, tagsByAreaId: {}, }; @@ -108,256 +79,6 @@ const areasSlice = createSlice({ state.areaList.loaded = timestamp; state.areaList.items.forEach((item) => (item.loaded = timestamp)); }, - assigneeAdd: (state, action: PayloadAction<[string, number]>) => { - const [canvassAssId, assigneeId] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items.push( - remoteItem(assigneeId, { isLoading: true }) - ); - }, - assigneeAdded: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee]> - ) => { - const [canvassAssId, assignee] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items = - state.assigneesByCanvassAssignmentId[canvassAssId].items - .filter((item) => item.id != assignee.id) - .concat([ - remoteItem(assignee.canvassAssId, { - data: assignee, - }), - ]); - }, - assigneeUpdated: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee]> - ) => { - const [canvassAssId, assignee] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items - .filter((item) => item.id == assignee.id) - .concat([ - remoteItem(assignee.canvassAssId, { - data: assignee, - }), - ]); - }, - assigneesLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].isLoading = true; - }, - assigneesLoaded: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee[]]> - ) => { - const [canvassAssId, assignees] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId] = - remoteList(assignees); - state.assigneesByCanvassAssignmentId[canvassAssId].loaded = - new Date().toISOString(); - }, - canvassAssignmentCreated: ( - state, - action: PayloadAction - ) => { - const canvassAssignment = action.payload; - const item = remoteItem(canvassAssignment.id, { - data: canvassAssignment, - loaded: new Date().toISOString(), - }); - - state.canvassAssignmentList.items.push(item); - }, - canvassAssignmentLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - const item = state.canvassAssignmentList.items.find( - (item) => item.id == canvassAssId - ); - - if (item) { - item.isLoading = true; - } else { - state.canvassAssignmentList.items = - state.canvassAssignmentList.items.concat([ - remoteItem(canvassAssId, { isLoading: true }), - ]); - } - }, - canvassAssignmentLoaded: ( - state, - action: PayloadAction - ) => { - const canvassAssignment = action.payload; - const item = state.canvassAssignmentList.items.find( - (item) => item.id == canvassAssignment.id - ); - - if (!item) { - throw new Error('Finished loading item that never started loading'); - } - - item.data = canvassAssignment; - item.isLoading = false; - item.loaded = new Date().toISOString(); - }, - canvassAssignmentUpdated: ( - state, - action: PayloadAction - ) => { - const assignment = action.payload; - const item = findOrAddItem(state.canvassAssignmentList, assignment.id); - - item.data = assignment; - item.loaded = new Date().toISOString(); - }, - canvassAssignmentsLoad: (state) => { - state.canvassAssignmentList.isLoading = true; - }, - canvassAssignmentsLoaded: ( - state, - action: PayloadAction - ) => { - state.canvassAssignmentList = remoteList(action.payload); - state.canvassAssignmentList.loaded = new Date().toISOString(); - }, - canvassSessionCreated: ( - state, - action: PayloadAction - ) => { - const session = action.payload; - if (!state.sessionsByAssignmentId[session.assignment.id]) { - state.sessionsByAssignmentId[session.assignment.id] = remoteList(); - } - const item = remoteItem(session.assignment.id, { - data: { ...session, id: session.assignee.id }, - loaded: new Date().toISOString(), - }); - - state.sessionsByAssignmentId[session.assignment.id].items.push(item); - }, - canvassSessionsLoad: (state, action: PayloadAction) => { - const assignmentId = action.payload; - - if (!state.sessionsByAssignmentId[assignmentId]) { - state.sessionsByAssignmentId[assignmentId] = remoteList(); - } - - state.sessionsByAssignmentId[assignmentId].isLoading = true; - }, - canvassSessionsLoaded: ( - state, - action: PayloadAction<[string, ZetkinCanvassSession[]]> - ) => { - const [assignmentId, sessions] = action.payload; - - state.sessionsByAssignmentId[assignmentId] = remoteList( - sessions.map((session) => ({ ...session, id: session.assignee.id })) - ); - - state.sessionsByAssignmentId[assignmentId].loaded = - new Date().toISOString(); - }, - myAssignmentsLoad: (state) => { - state.mySessionsList.isLoading = true; - }, - myAssignmentsLoaded: ( - state, - action: PayloadAction - ) => { - const sessions = action.payload; - const timestamp = new Date().toISOString(); - - state.mySessionsList = remoteList( - sessions.map((session) => ({ - ...session, - id: `${session.assignment.id} ${session.assignee.id}`, - })) - ); - state.mySessionsList.loaded = timestamp; - state.mySessionsList.items.forEach((item) => (item.loaded = timestamp)); - }, - placeCreated: (state, action: PayloadAction) => { - const place = action.payload; - const item = remoteItem(place.id, { - data: place, - loaded: new Date().toISOString(), - }); - - state.placeList.items.push(item); - }, - placeUpdated: (state, action: PayloadAction) => { - const place = action.payload; - const item = findOrAddItem(state.placeList, place.id); - - item.data = place; - item.loaded = new Date().toISOString(); - }, - placesLoad: (state) => { - state.placeList.isLoading = true; - }, - placesLoaded: (state, action: PayloadAction) => { - const timestamp = new Date().toISOString(); - const places = action.payload; - state.placeList = remoteList(places); - state.placeList.loaded = timestamp; - state.placeList.items.forEach((item) => (item.loaded = timestamp)); - }, - statsLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - const statsItem = state.statsByCanvassAssId[canvassAssId]; - - state.statsByCanvassAssId[canvassAssId] = remoteItem(canvassAssId, { - data: statsItem?.data || { - id: canvassAssId, - num_areas: 0, - num_households: 0, - num_places: 0, - num_visited_areas: 0, - num_visited_households: 0, - num_visited_households_outside_areas: 0, - num_visited_places: 0, - num_visited_places_outside_areas: 0, - }, - isLoading: true, - }); - }, - statsLoaded: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignmentStats]> - ) => { - const [canvassAssId, stats] = action.payload; - - state.statsByCanvassAssId[canvassAssId] = remoteItem(canvassAssId, { - data: { id: canvassAssId, ...stats }, - isLoading: false, - isStale: false, - loaded: new Date().toISOString(), - }); - }, tagAssigned: (state, action: PayloadAction<[string, ZetkinTag]>) => { const [areaId, tag] = action.payload; state.tagsByAreaId[areaId] ||= remoteList(); @@ -406,28 +127,6 @@ export const { areasLoad, areasLoaded, areaUpdated, - assigneeAdd, - assigneeAdded, - assigneeUpdated, - assigneesLoad, - assigneesLoaded, - myAssignmentsLoad, - myAssignmentsLoaded, - canvassAssignmentCreated, - canvassAssignmentLoad, - canvassAssignmentLoaded, - canvassAssignmentUpdated, - canvassAssignmentsLoad, - canvassAssignmentsLoaded, - canvassSessionCreated, - canvassSessionsLoad, - canvassSessionsLoaded, - placeCreated, - placesLoad, - placesLoaded, - placeUpdated, - statsLoad, - statsLoaded, tagAssigned, tagUnassigned, tagsLoad, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 6af250d74..716281563 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,31 +1,7 @@ -import { ZetkinPerson, ZetkinTag } from 'utils/types/zetkin'; - -export type ZetkinCanvassAssignmentStats = { - num_areas: number; - num_households: number; - num_places: number; - num_visited_areas: number; - num_visited_households: number; - num_visited_households_outside_areas: number; - num_visited_places: number; - num_visited_places_outside_areas: number; -}; +import { ZetkinTag } from 'utils/types/zetkin'; export type PointData = [number, number]; -export type Visit = { - canvassAssId: string | null; - id: string; - rating?: 'good' | 'bad'; - timestamp: string; -}; - -export type Household = { - id: string; - title: string; - visits: Visit[]; -}; - export type ZetkinArea = { description: string | null; id: string; @@ -37,65 +13,4 @@ export type ZetkinArea = { title: string | null; }; -export type ZetkinPlace = { - description: string | null; - households: Household[]; - id: string; - orgId: number; - position: { lat: number; lng: number }; - title: string | null; - type: 'address' | 'misc'; -}; - -export type ZetkinCanvassAssignment = { - campaign: { - id: number; - }; - id: string; - organization: { - id: number; - }; - title: string | null; -}; - -export type ZetkinCanvassSession = { - area: ZetkinArea; - assignee: ZetkinPerson; - assignment: { - id: string; - title: string | null; - }; -}; - -export type ZetkinCanvassSessionPostBody = { - areaId: string; - personId: number; -}; - export type ZetkinAreaPostBody = Partial>; -export type ZetkinPlacePostBody = Partial< - Omit ->; - -export type ZetkinPlacePatchBody = Partial< - Omit -> & { - households?: Partial> & - { visits?: Partial>[] }[]; -}; -export type ZetkinCanvassAssignmentPostBody = Partial< - Omit -> & { - campaign_id: number; -}; -export type ZetkinCanvassAssignmentPatchbody = Partial< - Omit ->; - -export type ZetkinCanvassAssignee = { - canvassAssId: string; - id: number; -}; -export type ZetkinCanvassAssigneePatchBody = Partial; - -export type HouseholdPatchBody = Partial>; diff --git a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx index 812a42c92..40e8630f7 100644 --- a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx +++ b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { Map } from '@mui/icons-material'; import ActivityListItem, { STATUS_COLORS } from './ActivityListItem'; -import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; +import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; type Props = { caId: string; diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index ac24a0c10..27b444108 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -29,7 +29,7 @@ import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; import ZUIDialog from 'zui/ZUIDialog'; import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; import { Msg, useMessages } from 'core/i18n'; -import useCreateCanvassAssignment from 'features/areas/hooks/useCreateCanvassAssignment'; +import useCreateCanvassAssignment from 'features/canvassAssignments/hooks/useCreateCanvassAssignment'; import useFeature from 'utils/featureFlags/useFeature'; import { AREAS } from 'utils/featureFlags'; @@ -126,6 +126,14 @@ const CampaignActionButtons: React.FunctionComponent< onClick: () => createCanvassAssignment({ campaign_id: campaign.id, + metrics: [ + { + definesDone: true, + description: '', + kind: 'boolean', + question: messages.form.createCanvassAssignment.defaultQuestion(), + }, + ], title: null, }), }); diff --git a/src/features/campaigns/hooks/useActivityArchive.ts b/src/features/campaigns/hooks/useActivityArchive.ts index 2c9dcaedb..2650cb986 100644 --- a/src/features/campaigns/hooks/useActivityArchive.ts +++ b/src/features/campaigns/hooks/useActivityArchive.ts @@ -1,4 +1,4 @@ -import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; +import useCanvassAssignmentActivities from 'features/canvassAssignments/hooks/useCanvassAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; diff --git a/src/features/campaigns/hooks/useActivityList.ts b/src/features/campaigns/hooks/useActivityList.ts index 6c5c2a63f..cdc92acc6 100644 --- a/src/features/campaigns/hooks/useActivityList.ts +++ b/src/features/campaigns/hooks/useActivityList.ts @@ -1,4 +1,4 @@ -import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; +import useCanvassAssignmentActivities from 'features/canvassAssignments/hooks/useCanvassAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; diff --git a/src/features/campaigns/hooks/useActivityOverview.ts b/src/features/campaigns/hooks/useActivityOverview.ts index 1862b776a..9ead95210 100644 --- a/src/features/campaigns/hooks/useActivityOverview.ts +++ b/src/features/campaigns/hooks/useActivityOverview.ts @@ -1,6 +1,6 @@ import { isSameDate } from 'utils/dateUtils'; import useCallAssignmentActivities from './useCallAssignmentActivities'; -import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; +import useCanvassAssignmentActivities from 'features/canvassAssignments/hooks/useCanvassAssignmentActivities'; import useEmailActivities from './useEmailActivities'; import useEventsFromDateRange from 'features/events/hooks/useEventsFromDateRange'; import useSurveyActivities from './useSurveyActivities'; @@ -34,8 +34,10 @@ export default function useActivitiyOverview( campId ); const emailActivitiesFuture = useEmailActivities(orgId, campId); - const canvassAssignmentAcitivitiesFuture = - useCanvassAssignmentActivities(orgId); + const canvassAssignmentAcitivitiesFuture = useCanvassAssignmentActivities( + orgId, + campId + ); if ( callAssignmentActivitiesFuture.isLoading || diff --git a/src/features/campaigns/l10n/messageIds.ts b/src/features/campaigns/l10n/messageIds.ts index 866eaab70..5fa616d46 100644 --- a/src/features/campaigns/l10n/messageIds.ts +++ b/src/features/campaigns/l10n/messageIds.ts @@ -76,6 +76,9 @@ export default makeMessages('feat.campaigns', { error: m('There was an error creating the project'), newCampaign: m('My project'), }, + createCanvassAssignment: { + defaultQuestion: m('Did you complete the mission?'), + }, createEmail: { newEmail: m('Untitled email'), }, diff --git a/src/features/campaigns/types.ts b/src/features/campaigns/types.ts index 25ef3444a..7ed1ca2eb 100644 --- a/src/features/campaigns/types.ts +++ b/src/features/campaigns/types.ts @@ -1,4 +1,4 @@ -import { ZetkinCanvassAssignment } from 'features/areas/types'; +import { ZetkinCanvassAssignment } from 'features/canvassAssignments/types'; import { ZetkinCallAssignment, ZetkinEmail, diff --git a/src/features/canvassAssignments/components/AssignmentMetricsChart.tsx b/src/features/canvassAssignments/components/AssignmentMetricsChart.tsx new file mode 100644 index 000000000..f3b783c73 --- /dev/null +++ b/src/features/canvassAssignments/components/AssignmentMetricsChart.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; +import { Box, lighten, useTheme } from '@mui/material'; +import { ResponsiveBar } from '@nivo/bar'; + +import { ZetkinCanvassAssignmentStats } from '../types'; + +type Props = { + stats: ZetkinCanvassAssignmentStats; +}; + +const AssignmentMetricsChart: FC = ({ stats }) => { + const theme = useTheme(); + + const metricBars = stats.metrics.map((response) => { + if (response.metric.kind == 'boolean') { + return { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + question: response.metric.question, + yes: response.values[0], + }; + } else { + return { + 1: response.values[0], + 2: response.values[1], + 3: response.values[2], + 4: response.values[3], + 5: response.values[4], + question: response.metric.question, + yes: 0, + }; + } + }); + + return ( + + + + ); +}; + +export default AssignmentMetricsChart; diff --git a/src/features/areas/components/CreatePlaceCard.tsx b/src/features/canvassAssignments/components/CreatePlaceCard.tsx similarity index 50% rename from src/features/areas/components/CreatePlaceCard.tsx rename to src/features/canvassAssignments/components/CreatePlaceCard.tsx index 1089be055..d08fe7ad5 100644 --- a/src/features/areas/components/CreatePlaceCard.tsx +++ b/src/features/canvassAssignments/components/CreatePlaceCard.tsx @@ -5,18 +5,11 @@ import { CardActions, CardContent, FormControl, - InputLabel, - MenuItem, - Select, - SelectChangeEvent, TextField, } from '@mui/material'; import { FC, useState } from 'react'; import { makeStyles } from '@mui/styles'; -import messageIds from '../l10n/messageIds'; -import { Msg, useMessages } from 'core/i18n'; - export const useStyles = makeStyles(() => ({ card: { bottom: 15, @@ -32,7 +25,7 @@ export const useStyles = makeStyles(() => ({ type AddPlaceDialogProps = { onClose: () => void; - onCreate: (title: string, type: string) => void; + onCreate: (title: string) => void; }; export const CreatePlaceCard: FC = ({ @@ -40,62 +33,35 @@ export const CreatePlaceCard: FC = ({ onCreate, }) => { const classes = useStyles(); - const messages = useMessages(messageIds); const [title, setTitle] = useState(''); - const [type, setType] = useState(''); - - const handleChange = (event: SelectChangeEvent) => { - setType(event.target.value); - }; return ( - - - - setTitle(ev.target.value)} - placeholder={ - type === 'address' - ? messages.placeCard.placeholderAddress() - : messages.placeCard.placeholderTitle() - } + placeholder="title" sx={{ paddingTop: 1 }} /> diff --git a/src/features/canvassAssignments/components/MetricCard.tsx b/src/features/canvassAssignments/components/MetricCard.tsx new file mode 100644 index 000000000..83b5d711b --- /dev/null +++ b/src/features/canvassAssignments/components/MetricCard.tsx @@ -0,0 +1,127 @@ +import { Close } from '@mui/icons-material'; +import React, { FC, useEffect, useState } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + TextField, + Checkbox, + Button, + IconButton, +} from '@mui/material'; + +import { ZetkinMetric } from '../types'; + +type MetricCardProps = { + hasDefinedDone: boolean; + isOnlyQuestion: boolean; + metric: ZetkinMetric; + onClose: () => void; + onDelete: (target: EventTarget & HTMLButtonElement) => void; + onSave: (metric: ZetkinMetric) => void; +}; + +const MetricCard: FC = ({ + hasDefinedDone, + isOnlyQuestion, + metric, + onClose, + onDelete, + onSave, +}) => { + const [question, setQuestion] = useState(metric.question || ''); + const [description, setDescription] = useState( + metric.description || '' + ); + const [definesDone, setDefinesDone] = useState( + metric.definesDone || false + ); + + const isEditing = !!metric?.id; + + useEffect(() => { + setQuestion(metric.question || ''); + setDescription(metric.description || ''); + setDefinesDone(metric.definesDone || false); + }, [metric]); + + const showDefinesDoneCheckbox = + metric.kind == 'boolean' && (metric.definesDone || !hasDefinedDone); + + return ( + + + + + {metric.kind === 'boolean' ? 'Yes/No Question' : 'Scale Question'} + + + + + + + {metric.kind == 'scale5' && ( + + The canvasser will respond by giving a rating from 1 to 5 + + )} + setQuestion(ev.target.value)} + sx={{ marginBottom: 2, marginTop: 2 }} + value={question} + variant="outlined" + /> + setDescription(ev.target.value)} + sx={{ marginBottom: 2 }} + value={description} + variant="outlined" + /> + {showDefinesDoneCheckbox && ( + + setDefinesDone(ev.target.checked)} + /> + + The answer to this question defines if the mission was + successful + + + )} + + {isEditing && !isOnlyQuestion && ( + + )} + + + + + + ); +}; + +export default MetricCard; diff --git a/src/features/areas/components/MyCanvassAssignmentsPage.tsx b/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx similarity index 83% rename from src/features/areas/components/MyCanvassAssignmentsPage.tsx rename to src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx index dbebd3f4b..71d384fa7 100644 --- a/src/features/areas/components/MyCanvassAssignmentsPage.tsx +++ b/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx @@ -11,13 +11,11 @@ import { Typography, } from '@mui/material'; -import useMyCanvassSessions from 'features/areas/hooks/useMyCanvassSessions'; -import { ZetkinCanvassSession } from '../types'; +import useMyCanvassSessions from '../hooks/useMyCanvassSessions'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; -import { Msg } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; +import { ZetkinCanvassSession } from '../types'; const CanvassAssignmentCard: FC<{ areaId: string; @@ -39,9 +37,7 @@ const CanvassAssignmentCard: FC<{ - {assignment.title || ( - - )} + {assignment.title || 'Untitled assignment'} {organization.title} @@ -54,7 +50,7 @@ const CanvassAssignmentCard: FC<{ } variant="outlined" > - + Go to map @@ -77,9 +73,7 @@ const MyCanvassAssignmentsPage: FC = () => { return ( - - - + Canvassing {Object.values(sessionsByAssignmentId).map((sessions) => { return ( void; + onTitleChange: (newTitle: string) => void; + title: string; +}; + +const EditPlace: FC = ({ + description, + onDescriptionChange, + onTitleChange, + title, +}) => { + return ( + + onTitleChange(ev.target.value)} + /> + onDescriptionChange(ev.target.value)} + rows={5} + /> + + ); +}; + +export default EditPlace; diff --git a/src/features/canvassAssignments/components/PlaceDialog/Household.tsx b/src/features/canvassAssignments/components/PlaceDialog/Household.tsx new file mode 100644 index 000000000..88fa9aff9 --- /dev/null +++ b/src/features/canvassAssignments/components/PlaceDialog/Household.tsx @@ -0,0 +1,112 @@ +import { Box, Button, TextField } from '@mui/material'; +import { FC } from 'react'; + +import { HouseholdPatchBody } from 'features/canvassAssignments/types'; + +type HouseholdProps = { + editingHouseholdTitle: boolean; + householdTitle: string; + onEditHouseholdTitleEnd: () => void; + onHouseholdTitleChange: (newTitle: string) => void; + onHouseholdUpdate: (data: HouseholdPatchBody) => void; + onWizardStart: () => void; + visitedRecently: boolean; +}; + +const Household: FC = ({ + householdTitle, + onHouseholdTitleChange, + editingHouseholdTitle, + onEditHouseholdTitleEnd, + onHouseholdUpdate, + onWizardStart, + visitedRecently, +}) => { + return ( + + + {editingHouseholdTitle && ( + + onHouseholdTitleChange(ev.target.value)} + placeholder="Household title" + value={householdTitle} + /> + + + + )} + + + {visitedRecently && + 'This household has been visted within the past 24 hours.'} + + + {/* + + + + {sortedVisits.length} + + + + {sortedVisits.length == 0 && ( + + )} + {sortedVisits.map((visit) => ( + + {messages.place.visitLog()} + + + + + ))} + */} + + + ); +}; + +export default Household; diff --git a/src/features/canvassAssignments/components/PlaceDialog/Place.tsx b/src/features/canvassAssignments/components/PlaceDialog/Place.tsx new file mode 100644 index 000000000..8fcc054b1 --- /dev/null +++ b/src/features/canvassAssignments/components/PlaceDialog/Place.tsx @@ -0,0 +1,83 @@ +import { Check } from '@mui/icons-material'; +import { Box, Divider, Typography } from '@mui/material'; +import { FC } from 'react'; + +import { isWithinLast24Hours } from 'features/canvassAssignments/utils/isWithinLast24Hours'; +import { ZetkinPlace } from 'features/canvassAssignments/types'; + +type PlaceProps = { + onSelectHousehold: (householdId: string) => void; + place: ZetkinPlace; +}; + +const Place: FC = ({ onSelectHousehold, place }) => { + return ( + + Description + + + {place.description || 'Empty description'} + + + + {`${place.households.length} household/s`} + + + + {place.households.map((household) => { + const visitedRecently = isWithinLast24Hours( + household.visits.map((t) => t.timestamp) + ); + /*const mostRecentVisit = household.visits.toSorted( + (a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + } + )[0];*/ + + return ( + { + onSelectHousehold(household.id); + }} + width="100%" + > + + {household.title || 'Untitled household'} + + {visitedRecently ? : ''} + + ); + })} + + + + ); +}; + +export default Place; diff --git a/src/features/canvassAssignments/components/PlaceDialog/VisitWizard.tsx b/src/features/canvassAssignments/components/PlaceDialog/VisitWizard.tsx new file mode 100644 index 000000000..9bd18ed4d --- /dev/null +++ b/src/features/canvassAssignments/components/PlaceDialog/VisitWizard.tsx @@ -0,0 +1,224 @@ +import { + Box, + Button, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { FC, useState } from 'react'; + +import { + Visit, + ZetkinCanvassAssignment, + ZetkinMetric, +} from 'features/canvassAssignments/types'; + +const Question: FC<{ + metric: ZetkinMetric; + onChange: (newValue: string | null) => void; + value?: string; +}> = ({ onChange, metric, value }) => { + const options = + metric.kind == 'boolean' + ? [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ] + : [ + { label: 1, value: '1' }, + { label: 2, value: '2' }, + { label: 3, value: '3' }, + { label: 4, value: '4' }, + { label: 5, value: '5' }, + ]; + + return ( + <> + + {metric.question} + {metric.description} + + {!metric.definesDone && ( + + )} + { + onChange(newValue); + }} + value={value} + > + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; + +type PreviousMessageProps = { + onClick: () => void; + question: string; + response: string; +}; + +const PreviousMessage: FC = ({ + onClick, + question, + response, +}) => { + return ( + + {!response && ( + + {`${question}:`} + + {'Skipped'} + + + )} + {response && ( + {`${question}: ${response}`} + )} + + ); +}; + +type VisitWizardProps = { + metrics: ZetkinCanvassAssignment['metrics']; + onLogVisit: ( + responses: Visit['responses'], + noteToOfficial: Visit['noteToOfficial'] + ) => void; +}; + +const VisitWizard: FC = ({ metrics, onLogVisit }) => { + const [responses, setResponses] = useState([]); + const [step, setStep] = useState(0); + const [noteToOfficial, setNoteToOfficial] = useState(''); + + return ( + + {metrics.map((metric, index) => { + if (index < step) { + return ( + <> + { + setStep(index); + setResponses(responses.slice(0, index + 1)); + }} + question={metric.question} + response={responses[index].response} + /> + {index == metrics.length - 1 && ( + + + + Did anything happen that an official needs to know about? + + setNoteToOfficial(ev.target.value)} + value={noteToOfficial} + /> + + + + )} + + ); + } + + if (index == step) { + return ( + + { + if (newValue == null) { + //User is returning and selects the same response + setStep(step + 1); + } else { + if (responses[index]) { + //User is returning and selects new response + setResponses([ + ...responses.slice( + 0, + responses.indexOf(responses[index]) + ), + { + ...responses[index], + response: newValue.toString(), + }, + ]); + } else { + //User is responding to this question for the first time + setResponses([ + ...responses, + { + metricId: metric.id, + response: newValue.toString(), + }, + ]); + } + setStep(step + 1); + } + }} + value={responses[index]?.response} + /> + + ); + } + + if (index > step) { + return null; + } + })} + + ); +}; + +export default VisitWizard; diff --git a/src/features/canvassAssignments/components/PlaceDialog/index.tsx b/src/features/canvassAssignments/components/PlaceDialog/index.tsx new file mode 100644 index 000000000..4db5fc320 --- /dev/null +++ b/src/features/canvassAssignments/components/PlaceDialog/index.tsx @@ -0,0 +1,391 @@ +import { + ArrowBackIos, + Check, + Close, + Edit, + MoreVert, +} from '@mui/icons-material'; +import { FC, useState } from 'react'; +import { + Box, + Button, + ButtonGroup, + Dialog, + Divider, + IconButton, + Menu, + MenuItem, + Typography, +} from '@mui/material'; + +import VisitWizard from './VisitWizard'; +import EditPlace from './EditPlace'; +import Place from './Place'; +import Household from './Household'; +import { isWithinLast24Hours } from 'features/canvassAssignments/utils/isWithinLast24Hours'; +import ZUIFuture from 'zui/ZUIFuture'; +import { PlaceDialogStep } from '../PublicAreaMap'; +import { ZetkinPlace } from 'features/canvassAssignments/types'; +import usePlaceMutations from 'features/canvassAssignments/hooks/usePlaceMutations'; +import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; + +type PlaceDialogProps = { + canvassAssId: string; + dialogStep: PlaceDialogStep; + onClose: () => void; + onEdit: () => void; + onPickHousehold: () => void; + onSelectHousehold: () => void; + onUpdateDone: () => void; + onWizard: () => void; + open: boolean; + orgId: number; + place: ZetkinPlace; +}; + +const PlaceDialog: FC = ({ + canvassAssId, + dialogStep, + onClose, + onEdit, + onPickHousehold, + onUpdateDone, + onSelectHousehold, + onWizard, + open, + orgId, + place, +}) => { + const { addVisit, addHousehold, updateHousehold, updatePlace } = + usePlaceMutations(orgId, place.id); + const assignmentFuture = useCanvassAssignment(orgId, canvassAssId); + + const [selectedHouseholdId, setSelectedHouseholdId] = useState( + null + ); + const [description, setDescription] = useState( + place.description ?? '' + ); + const [title, setTitle] = useState(place.title ?? ''); + const [editingHouseholdTitle, setEditingHouseholdTitle] = useState(false); + const [householdTitle, setHousholdTitle] = useState(''); + const [anchorEl, setAnchorEl] = useState(null); + + const selectedHousehold = place.households.find( + (household) => household.id == selectedHouseholdId + ); + + /*const sortedVisits = + selectedHousehold?.visits.toSorted((a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + }) || [];*/ + + const nothingHasBeenEdited = + dialogStep == 'edit' && + title == place.title && + (description == place.description || (!description && !place.description)); + + const saveButtonDisabled = nothingHasBeenEdited; + + const showWizard = selectedHousehold && dialogStep == 'wizard'; + + return ( + + + {(assignment) => ( + + + {dialogStep == 'place' && ( + <> + + {place?.title || 'Untitled place'} + + + + + + + + + + + )} + {dialogStep == 'pickHousehold' && ( + + + { + onUpdateDone(); + }} + > + + + Log visit + + + + + + )} + {dialogStep == 'edit' && ( + <> + + {`Edit ${place.title || 'Untitled place'}`} + + + + + + )} + {dialogStep == 'household' && selectedHousehold && ( + + + { + onUpdateDone(); + }} + > + + + + {selectedHousehold.title || 'Untitled household'} + + + + { + setHousholdTitle(selectedHousehold.title); + setEditingHouseholdTitle(true); + }} + sx={{ marginRight: 1 }} + > + + + + + )} + {dialogStep == 'wizard' && selectedHousehold && ( + + { + onSelectHousehold(); + }} + > + + + + {selectedHousehold.title || 'Untitled household'} + + + )} + + + + {place && dialogStep == 'place' && ( + { + setSelectedHouseholdId(householdId); + onSelectHousehold(); + }} + place={place} + /> + )} + {dialogStep == 'pickHousehold' && ( + + Choose household + {place.households.map((household) => { + const visitedRecently = isWithinLast24Hours( + household.visits.map((t) => t.timestamp) + ); + return ( + { + if (!visitedRecently) { + setSelectedHouseholdId(household.id); + onWizard(); + } + }} + width="100%" + > + + + {household.title || 'Untitled household'} + + + {visitedRecently ? : ''} + + ); + })} + + )} + {dialogStep === 'edit' && ( + + setDescription(newDescription) + } + onTitleChange={(newTitle) => setTitle(newTitle)} + title={title} + /> + )} + {selectedHousehold && dialogStep == 'household' && ( + + setEditingHouseholdTitle(false) + } + onHouseholdTitleChange={(newTitle) => + setHousholdTitle(newTitle) + } + onHouseholdUpdate={(data) => + updateHousehold(selectedHousehold.id, data) + } + onWizardStart={() => { + onWizard(); + }} + visitedRecently={isWithinLast24Hours( + selectedHousehold.visits.map((t) => t.timestamp) + )} + /> + )} + {showWizard && ( + { + addVisit(selectedHousehold.id, { + canvassAssId: assignment.id, + noteToOfficial, + responses, + timestamp: new Date().toISOString(), + }); + onUpdateDone(); + }} + /> + )} + + + {dialogStep === 'place' && place.households.length == 0 && ( + + )} + {dialogStep == 'place' && place.households.length == 1 && ( + + + + + )} + {dialogStep == 'place' && place.households.length > 1 && ( + + + + + )} + + {dialogStep == 'edit' && ( + + )} + setAnchorEl(null)} + open={!!anchorEl} + transformOrigin={{ + horizontal: 'center', + vertical: 'bottom', + }} + > + { + const newlyAddedHousehold = await addHousehold(); + setSelectedHouseholdId(newlyAddedHousehold.id); + onSelectHousehold(); + setEditingHouseholdTitle(true); + setAnchorEl(null); + }} + > + Add household + + + + + )} + + + ); +}; + +export default PlaceDialog; diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/canvassAssignments/components/PlanMap.tsx similarity index 87% rename from src/features/areas/components/PlanMap.tsx rename to src/features/canvassAssignments/components/PlanMap.tsx index 3d7dc92c7..16ba20da5 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/canvassAssignments/components/PlanMap.tsx @@ -12,12 +12,11 @@ import { } from '@mui/material'; import { Add, Remove } from '@mui/icons-material'; -import { ZetkinArea, ZetkinCanvassSession } from '../types'; -import { useMessages } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; +import { ZetkinArea } from '../../areas/types'; import PlanMapRenderer from './PlanMapRenderer'; -import AreaPlanningOverlay from './AreaPlanningOverlay'; +import AreaPlanningOverlay from '../../areas/components/AreaPlanningOverlay'; import { ZetkinPerson } from 'utils/types/zetkin'; +import { ZetkinCanvassSession } from '../types'; type PlanMapProps = { areas: ZetkinArea[]; @@ -30,7 +29,6 @@ const PlanMap: FC = ({ onAddAssigneeToArea, sessions, }) => { - const messages = useMessages(messageIds); const [filterAssigned, setFilterAssigned] = useState(false); const [filterUnassigned, setFilterUnassigned] = useState(false); @@ -48,8 +46,8 @@ const PlanMap: FC = ({ } return areas.filter((area) => { - const areaTitle = area.title || messages.empty.title(); - const areaDesc = area.description || messages.empty.description(); + const areaTitle = area.title || 'Untitled area'; + const areaDesc = area.description || 'Empty description'; return ( areaTitle.toLowerCase().includes(inputValue) || @@ -91,12 +89,12 @@ const PlanMap: FC = ({ setFilterAssigned(!filterAssigned)} /> setFilterUnassigned(!filterUnassigned)} /> = ({ /> )} renderOption={(props, area) => ( - - {area.title || messages.empty.title()} - + {area.title || 'Untitled area'} )} value={null} /> diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/canvassAssignments/components/PlanMapRenderer.tsx similarity index 97% rename from src/features/areas/components/PlanMapRenderer.tsx rename to src/features/canvassAssignments/components/PlanMapRenderer.tsx index cdd17b6d4..bae40e46f 100644 --- a/src/features/areas/components/PlanMapRenderer.tsx +++ b/src/features/canvassAssignments/components/PlanMapRenderer.tsx @@ -10,10 +10,11 @@ import { FeatureGroup as FeatureGroupType, latLngBounds } from 'leaflet'; import { useTheme } from '@mui/styles'; import { Box } from '@mui/material'; -import { ZetkinArea, ZetkinCanvassSession } from '../types'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; import ZUIAvatar from 'zui/ZUIAvatar'; -import objToLatLng from '../utils/objToLatLng'; +import { ZetkinCanvassSession } from '../types'; +import { ZetkinArea } from 'features/areas/types'; +import objToLatLng from 'features/areas/utils/objToLatLng'; type PlanMapRendererProps = { areas: ZetkinArea[]; diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/canvassAssignments/components/PublicAreaMap.tsx similarity index 94% rename from src/features/areas/components/PublicAreaMap.tsx rename to src/features/canvassAssignments/components/PublicAreaMap.tsx index 771fed46f..291cc9bb6 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/canvassAssignments/components/PublicAreaMap.tsx @@ -17,16 +17,14 @@ import { TileLayer, } from 'react-leaflet'; -import { ZetkinArea } from '../types'; -import { Msg } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; -import { CreatePlaceCard } from './CreatePlaceCard'; +import { ZetkinArea } from '../../areas/types'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; -import PlaceDialog from './PlaceDialog'; import useCreatePlace from '../hooks/useCreatePlace'; import usePlaces from '../hooks/usePlaces'; import getCrosshairPositionOnMap from '../utils/getCrosshairPositionOnMap'; import MarkerIcon from '../utils/markerIcon'; +import PlaceDialog from './PlaceDialog'; +import { CreatePlaceCard } from './CreatePlaceCard'; const useStyles = makeStyles((theme) => ({ '@keyframes ghostMarkerBounce': { @@ -87,12 +85,19 @@ const useStyles = makeStyles((theme) => ({ }, })); +export type PlaceDialogStep = + | 'place' + | 'edit' + | 'household' + | 'wizard' + | 'pickHousehold'; + type PublicAreaMapProps = { area: ZetkinArea; canvassAssId: string | null; }; -const PublicAreaMap: FC = ({ area, canvassAssId }) => { +const PublicAreaMap: FC = ({ canvassAssId, area }) => { const theme = useTheme(); const classes = useStyles(); const places = usePlaces(area.organization.id).data || []; @@ -100,9 +105,7 @@ const PublicAreaMap: FC = ({ area, canvassAssId }) => { const [selectedPlaceId, setSelectedPlaceId] = useState(null); const [anchorEl, setAnchorEl] = useState(null); - const [dialogStep, setDialogStep] = useState<'place' | 'edit' | 'household'>( - 'place' - ); + const [dialogStep, setDialogStep] = useState('place'); const [standingStill, setStandingStill] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -254,7 +257,7 @@ const PublicAreaMap: FC = ({ area, canvassAssId }) => { {showViewPlaceButton && ( - {selectedPlace.title || } + {selectedPlace.title || 'Untitled place'} )} {!selectedPlace && !isCreating && ( )} @@ -313,7 +316,7 @@ const PublicAreaMap: FC = ({ area, canvassAssId }) => { })} - {selectedPlace && ( + {selectedPlace && canvassAssId && ( = ({ area, canvassAssId }) => { setSelectedPlaceId(null); }} onEdit={() => setDialogStep('edit')} + onPickHousehold={() => setDialogStep('pickHousehold')} onSelectHousehold={() => setDialogStep('household')} onUpdateDone={() => setDialogStep('place')} + onWizard={() => setDialogStep('wizard')} open={!!anchorEl} orgId={area.organization.id} place={selectedPlace} @@ -334,7 +339,7 @@ const PublicAreaMap: FC = ({ area, canvassAssId }) => { onClose={() => { setIsCreating(false); }} - onCreate={(title, type) => { + onCreate={(title) => { const crosshair = crosshairRef.current; if (crosshair && map) { @@ -348,7 +353,6 @@ const PublicAreaMap: FC = ({ area, canvassAssId }) => { createPlace({ position: point, title, - type: type === 'address' ? 'address' : 'misc', }); } } diff --git a/src/features/areas/components/AreaPage.tsx b/src/features/canvassAssignments/components/PublicAreaPage.tsx similarity index 81% rename from src/features/areas/components/AreaPage.tsx rename to src/features/canvassAssignments/components/PublicAreaPage.tsx index cde2ff73c..2d0394040 100644 --- a/src/features/areas/components/AreaPage.tsx +++ b/src/features/canvassAssignments/components/PublicAreaPage.tsx @@ -5,21 +5,19 @@ import { FC } from 'react'; import dynamic from 'next/dynamic'; import { useSearchParams } from 'next/navigation'; -import useArea from '../hooks/useArea'; +import useArea from '../../areas/hooks/useArea'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; -import { Msg } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; import useServerSide from 'core/useServerSide'; const PublicAreaMap = dynamic(() => import('./PublicAreaMap'), { ssr: false }); -type AreaPageProps = { +type PublicAreaPageProps = { areaId: string; orgId: number; }; -const AreaPage: FC = ({ areaId, orgId }) => { +const PublicAreaPage: FC = ({ areaId, orgId }) => { const orgFuture = useOrganization(orgId); const areaFuture = useArea(orgId, areaId); const searchParams = useSearchParams(); @@ -48,9 +46,9 @@ const AreaPage: FC = ({ areaId, orgId }) => { {org.title} - {area.title ?? } + {area.title ?? 'Untitled canvassassignment'} - {area.description ?? } + {area.description ?? 'Untitled area'} @@ -63,4 +61,4 @@ const AreaPage: FC = ({ areaId, orgId }) => { ); }; -export default AreaPage; +export default PublicAreaPage; diff --git a/src/features/areas/hooks/useAddAssignee.ts b/src/features/canvassAssignments/hooks/useAddAssignee.ts similarity index 100% rename from src/features/areas/hooks/useAddAssignee.ts rename to src/features/canvassAssignments/hooks/useAddAssignee.ts diff --git a/src/features/areas/hooks/useAssigneeMutations.ts b/src/features/canvassAssignments/hooks/useAssigneeMutations.ts similarity index 100% rename from src/features/areas/hooks/useAssigneeMutations.ts rename to src/features/canvassAssignments/hooks/useAssigneeMutations.ts diff --git a/src/features/areas/hooks/useAssignees.ts b/src/features/canvassAssignments/hooks/useAssignees.ts similarity index 89% rename from src/features/areas/hooks/useAssignees.ts rename to src/features/canvassAssignments/hooks/useAssignees.ts index e0f707fbd..490cbf910 100644 --- a/src/features/areas/hooks/useAssignees.ts +++ b/src/features/canvassAssignments/hooks/useAssignees.ts @@ -7,7 +7,8 @@ export default function useAssignees(orgId: number, canvassAssId: string) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const assigneeList = useAppSelector( - (state) => state.areas.assigneesByCanvassAssignmentId[canvassAssId] + (state) => + state.canvassAssignments.assigneesByCanvassAssignmentId[canvassAssId] ); return loadListIfNecessary(assigneeList, dispatch, { diff --git a/src/features/areas/hooks/useCanvassAssignment.ts b/src/features/canvassAssignments/hooks/useCanvassAssignment.ts similarity index 93% rename from src/features/areas/hooks/useCanvassAssignment.ts rename to src/features/canvassAssignments/hooks/useCanvassAssignment.ts index 53b194fd9..ff8052193 100644 --- a/src/features/areas/hooks/useCanvassAssignment.ts +++ b/src/features/canvassAssignments/hooks/useCanvassAssignment.ts @@ -10,7 +10,7 @@ export default function useCanvassAssignment( const apiClient = useApiClient(); const dispatch = useAppDispatch(); const canvassAssignmenList = useAppSelector( - (state) => state.areas.canvassAssignmentList.items + (state) => state.canvassAssignments.canvassAssignmentList.items ); const canvassAssignmentItem = canvassAssignmenList.find( (item) => item.id == canvassAssId diff --git a/src/features/areas/hooks/useCanvassAssignmentActivities.ts b/src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts similarity index 94% rename from src/features/areas/hooks/useCanvassAssignmentActivities.ts rename to src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts index 5b99900cc..dfe3db4c3 100644 --- a/src/features/areas/hooks/useCanvassAssignmentActivities.ts +++ b/src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts @@ -18,7 +18,9 @@ export default function useCanvassAssignmentActivities( ): IFuture { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const list = useAppSelector((state) => state.areas.canvassAssignmentList); + const list = useAppSelector( + (state) => state.canvassAssignments.canvassAssignmentList + ); const hasCanvassing = useFeature(AREAS); if (!hasCanvassing) { diff --git a/src/features/areas/hooks/useCanvassAssignmentMutations.ts b/src/features/canvassAssignments/hooks/useCanvassAssignmentMutations.ts similarity index 100% rename from src/features/areas/hooks/useCanvassAssignmentMutations.ts rename to src/features/canvassAssignments/hooks/useCanvassAssignmentMutations.ts diff --git a/src/features/areas/hooks/useCanvassAssignmentStats.ts b/src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts similarity index 91% rename from src/features/areas/hooks/useCanvassAssignmentStats.ts rename to src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts index dd00259df..e32634341 100644 --- a/src/features/areas/hooks/useCanvassAssignmentStats.ts +++ b/src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts @@ -10,7 +10,7 @@ export default function useCanvassAssignmentStats( const apiClient = useApiClient(); const dispatch = useAppDispatch(); const statsItem = useAppSelector( - (state) => state.areas.statsByCanvassAssId[canvassAssId] + (state) => state.canvassAssignments.statsByCanvassAssId[canvassAssId] ); return loadItemIfNecessary(statsItem, dispatch, { diff --git a/src/features/areas/hooks/useCanvassSessions.ts b/src/features/canvassAssignments/hooks/useCanvassSessions.ts similarity index 91% rename from src/features/areas/hooks/useCanvassSessions.ts rename to src/features/canvassAssignments/hooks/useCanvassSessions.ts index 0bff66bda..d2b944a24 100644 --- a/src/features/areas/hooks/useCanvassSessions.ts +++ b/src/features/canvassAssignments/hooks/useCanvassSessions.ts @@ -10,7 +10,7 @@ export default function useCanvassSessions( const apiClient = useApiClient(); const dispatch = useAppDispatch(); const sessions = useAppSelector( - (state) => state.areas.sessionsByAssignmentId[canvassAssId] + (state) => state.canvassAssignments.sessionsByAssignmentId[canvassAssId] ); return loadListIfNecessary(sessions, dispatch, { diff --git a/src/features/areas/hooks/useCreateCanvassAssignment.ts b/src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts similarity index 73% rename from src/features/areas/hooks/useCreateCanvassAssignment.ts rename to src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts index c3b8c419d..866ba9502 100644 --- a/src/features/areas/hooks/useCreateCanvassAssignment.ts +++ b/src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts @@ -10,10 +10,10 @@ export default function useCreateCanvassAssignment(orgId: number) { const dispatch = useAppDispatch(); return async (data: ZetkinCanvassAssignmentPostBody) => { - const created = await apiClient.post( - `/beta/orgs/${orgId}/canvassassignments`, - data - ); + const created = await apiClient.post< + ZetkinCanvassAssignment, + ZetkinCanvassAssignmentPostBody + >(`/beta/orgs/${orgId}/canvassassignments`, data); dispatch(canvassAssignmentCreated(created)); }; } diff --git a/src/features/areas/hooks/useCreateCanvassSession.ts b/src/features/canvassAssignments/hooks/useCreateCanvassSession.ts similarity index 100% rename from src/features/areas/hooks/useCreateCanvassSession.ts rename to src/features/canvassAssignments/hooks/useCreateCanvassSession.ts diff --git a/src/features/areas/hooks/useCreatePlace.ts b/src/features/canvassAssignments/hooks/useCreatePlace.ts similarity index 100% rename from src/features/areas/hooks/useCreatePlace.ts rename to src/features/canvassAssignments/hooks/useCreatePlace.ts diff --git a/src/features/areas/hooks/useMyCanvassSessions.ts b/src/features/canvassAssignments/hooks/useMyCanvassSessions.ts similarity index 87% rename from src/features/areas/hooks/useMyCanvassSessions.ts rename to src/features/canvassAssignments/hooks/useMyCanvassSessions.ts index 5a4cd0375..aa9776560 100644 --- a/src/features/areas/hooks/useMyCanvassSessions.ts +++ b/src/features/canvassAssignments/hooks/useMyCanvassSessions.ts @@ -6,7 +6,9 @@ import { ZetkinCanvassSession } from '../types'; export default function useMyCanvassSessions() { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const mySessions = useAppSelector((state) => state.areas.mySessionsList); + const mySessions = useAppSelector( + (state) => state.canvassAssignments.mySessionsList + ); return loadListIfNecessary(mySessions, dispatch, { actionOnLoad: () => myAssignmentsLoad(), diff --git a/src/features/areas/hooks/usePlaceMutations.ts b/src/features/canvassAssignments/hooks/usePlaceMutations.ts similarity index 100% rename from src/features/areas/hooks/usePlaceMutations.ts rename to src/features/canvassAssignments/hooks/usePlaceMutations.ts diff --git a/src/features/areas/hooks/usePlaces.ts b/src/features/canvassAssignments/hooks/usePlaces.ts similarity index 86% rename from src/features/areas/hooks/usePlaces.ts rename to src/features/canvassAssignments/hooks/usePlaces.ts index dc6ea2b78..c8fc28ace 100644 --- a/src/features/areas/hooks/usePlaces.ts +++ b/src/features/canvassAssignments/hooks/usePlaces.ts @@ -6,7 +6,9 @@ import { placesLoad, placesLoaded } from '../store'; export default function usePlaces(orgId: number) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const placeList = useAppSelector((state) => state.areas.placeList); + const placeList = useAppSelector( + (state) => state.canvassAssignments.placeList + ); return loadListIfNecessary(placeList, dispatch, { actionOnLoad: () => placesLoad(), diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx similarity index 81% rename from src/features/areas/layouts/CanvassAssignmentLayout.tsx rename to src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index 75b1a8e19..c35e1dd53 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -1,9 +1,7 @@ import { FC, ReactNode } from 'react'; import { useRouter } from 'next/router'; -import { useMessages } from 'core/i18n'; import TabbedLayout from 'utils/layout/TabbedLayout'; -import messageIds from '../l10n/messageIds'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; import useCanvassAssignmentMutations from '../hooks/useCanvassAssignmentMutations'; @@ -22,7 +20,6 @@ const CanvassAssignmentLayout: FC = ({ canvassAssId, }) => { const path = useRouter().pathname; - const messages = useMessages(messageIds); const canvassAssignment = useCanvassAssignment(orgId, canvassAssId).data; const updateCanvassAssignment = useCanvassAssignmentMutations( orgId, @@ -41,16 +38,15 @@ const CanvassAssignmentLayout: FC = ({ defaultTab="/" fixedHeight={isPlanTab} tabs={[ - { href: '/', label: messages.canvassAssignment.tabs.overview() }, + { href: '/', label: 'Overview' }, { href: '/plan', label: 'Plan' }, { href: '/canvassers', label: 'Canvassers' }, + { href: '/editor', label: 'Editor' }, ]} title={ updateCanvassAssignment({ title: newTitle })} - value={ - canvassAssignment.title || messages.canvassAssignment.empty.title() - } + value={canvassAssignment.title || 'Untitled canvass assignment'} /> } > diff --git a/src/features/canvassAssignments/models.ts b/src/features/canvassAssignments/models.ts new file mode 100644 index 000000000..09f94f6b0 --- /dev/null +++ b/src/features/canvassAssignments/models.ts @@ -0,0 +1,90 @@ +import mongoose from 'mongoose'; + +import { ZetkinCanvassAssignee, ZetkinMetric, ZetkinPlace } from './types'; + +type ZetkinCanvassAssignmentModelType = { + campId: number; + id: number; + metrics: (Omit & { _id: string })[]; + orgId: number; + sessions: { + areaId: string; + personId: number; + }[]; + title: string | null; +}; + +type ZetkinPlaceModelType = Omit; + +const canvassAssignmentSchema = + new mongoose.Schema({ + campId: Number, + metrics: [ + { + definesDone: Boolean, + description: String, + id: String, + kind: String, + question: String, + }, + ], + orgId: { required: true, type: Number }, + sessions: [ + { + areaId: String, + personId: Number, + }, + ], + title: String, + }); + +const placeSchema = new mongoose.Schema({ + description: String, + households: [ + { + _id: false, + id: String, + title: String, + visits: [ + { + _id: false, + canvassAssId: String, + id: String, + noteToOfficial: String, + responses: [ + { + metricId: String, + response: String, + }, + ], + timestamp: String, + }, + ], + }, + ], + orgId: { required: true, type: Number }, + position: Object, + title: String, +}); + +const canvassAssigneeSchema = new mongoose.Schema({ + canvassAssId: String, + id: { required: true, type: Number }, +}); + +export const CanvassAssignmentModel: mongoose.Model = + mongoose.models.CanvassAssignment || + mongoose.model( + 'CanvassAssignment', + canvassAssignmentSchema + ); +export const PlaceModel: mongoose.Model = + mongoose.models.Place || + mongoose.model('Place', placeSchema); + +export const CanvassAssigneeModel: mongoose.Model = + mongoose.models.CanvassAssignee || + mongoose.model( + 'CanvassAssignee', + canvassAssigneeSchema + ); diff --git a/src/features/canvassAssignments/store.ts b/src/features/canvassAssignments/store.ts new file mode 100644 index 000000000..f135aa31c --- /dev/null +++ b/src/features/canvassAssignments/store.ts @@ -0,0 +1,316 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + findOrAddItem, + RemoteItem, + remoteItem, + remoteList, + RemoteList, +} from 'utils/storeUtils'; +import { + ZetkinCanvassAssignmentStats, + ZetkinCanvassAssignee, + ZetkinCanvassAssignment, + ZetkinCanvassSession, + ZetkinPlace, +} from './types'; + +export interface CanvassAssignmentsStoreSlice { + canvassAssignmentList: RemoteList; + sessionsByAssignmentId: Record< + string, + RemoteList + >; + assigneesByCanvassAssignmentId: Record< + string, + RemoteList + >; + mySessionsList: RemoteList; + placeList: RemoteList; + statsByCanvassAssId: Record< + string, + RemoteItem + >; +} + +const initialState: CanvassAssignmentsStoreSlice = { + assigneesByCanvassAssignmentId: {}, + canvassAssignmentList: remoteList(), + mySessionsList: remoteList(), + placeList: remoteList(), + sessionsByAssignmentId: {}, + statsByCanvassAssId: {}, +}; + +const canvassAssignmentSlice = createSlice({ + initialState: initialState, + name: 'canvassAssignments', + reducers: { + assigneeAdd: (state, action: PayloadAction<[string, number]>) => { + const [canvassAssId, assigneeId] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].items.push( + remoteItem(assigneeId, { isLoading: true }) + ); + }, + assigneeAdded: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee]> + ) => { + const [canvassAssId, assignee] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].items = + state.assigneesByCanvassAssignmentId[canvassAssId].items + .filter((item) => item.id != assignee.id) + .concat([ + remoteItem(assignee.canvassAssId, { + data: assignee, + }), + ]); + }, + assigneeUpdated: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee]> + ) => { + const [canvassAssId, assignee] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].items + .filter((item) => item.id == assignee.id) + .concat([ + remoteItem(assignee.canvassAssId, { + data: assignee, + }), + ]); + }, + assigneesLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].isLoading = true; + }, + assigneesLoaded: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee[]]> + ) => { + const [canvassAssId, assignees] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId] = + remoteList(assignees); + state.assigneesByCanvassAssignmentId[canvassAssId].loaded = + new Date().toISOString(); + }, + canvassAssignmentCreated: ( + state, + action: PayloadAction + ) => { + const canvassAssignment = action.payload; + const item = remoteItem(canvassAssignment.id, { + data: canvassAssignment, + loaded: new Date().toISOString(), + }); + + state.canvassAssignmentList.items.push(item); + }, + canvassAssignmentLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + const item = state.canvassAssignmentList.items.find( + (item) => item.id == canvassAssId + ); + + if (item) { + item.isLoading = true; + } else { + state.canvassAssignmentList.items = + state.canvassAssignmentList.items.concat([ + remoteItem(canvassAssId, { isLoading: true }), + ]); + } + }, + canvassAssignmentLoaded: ( + state, + action: PayloadAction + ) => { + const canvassAssignment = action.payload; + const item = state.canvassAssignmentList.items.find( + (item) => item.id == canvassAssignment.id + ); + + if (!item) { + throw new Error('Finished loading item that never started loading'); + } + + item.data = canvassAssignment; + item.isLoading = false; + item.loaded = new Date().toISOString(); + }, + canvassAssignmentUpdated: ( + state, + action: PayloadAction + ) => { + const assignment = action.payload; + const item = findOrAddItem(state.canvassAssignmentList, assignment.id); + + item.data = assignment; + item.loaded = new Date().toISOString(); + }, + canvassAssignmentsLoad: (state) => { + state.canvassAssignmentList.isLoading = true; + }, + canvassAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + state.canvassAssignmentList = remoteList(action.payload); + state.canvassAssignmentList.loaded = new Date().toISOString(); + }, + canvassSessionCreated: ( + state, + action: PayloadAction + ) => { + const session = action.payload; + if (!state.sessionsByAssignmentId[session.assignment.id]) { + state.sessionsByAssignmentId[session.assignment.id] = remoteList(); + } + const item = remoteItem(session.assignment.id, { + data: { ...session, id: session.assignee.id }, + loaded: new Date().toISOString(), + }); + + state.sessionsByAssignmentId[session.assignment.id].items.push(item); + }, + canvassSessionsLoad: (state, action: PayloadAction) => { + const assignmentId = action.payload; + + if (!state.sessionsByAssignmentId[assignmentId]) { + state.sessionsByAssignmentId[assignmentId] = remoteList(); + } + + state.sessionsByAssignmentId[assignmentId].isLoading = true; + }, + canvassSessionsLoaded: ( + state, + action: PayloadAction<[string, ZetkinCanvassSession[]]> + ) => { + const [assignmentId, sessions] = action.payload; + + state.sessionsByAssignmentId[assignmentId] = remoteList( + sessions.map((session) => ({ ...session, id: session.assignee.id })) + ); + + state.sessionsByAssignmentId[assignmentId].loaded = + new Date().toISOString(); + }, + myAssignmentsLoad: (state) => { + state.mySessionsList.isLoading = true; + }, + myAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + const sessions = action.payload; + const timestamp = new Date().toISOString(); + + state.mySessionsList = remoteList( + sessions.map((session) => ({ + ...session, + id: `${session.assignment.id} ${session.assignee.id}`, + })) + ); + state.mySessionsList.loaded = timestamp; + state.mySessionsList.items.forEach((item) => (item.loaded = timestamp)); + }, + placeCreated: (state, action: PayloadAction) => { + const place = action.payload; + const item = remoteItem(place.id, { + data: place, + loaded: new Date().toISOString(), + }); + + state.placeList.items.push(item); + }, + placeUpdated: (state, action: PayloadAction) => { + const place = action.payload; + const item = findOrAddItem(state.placeList, place.id); + + item.data = place; + item.loaded = new Date().toISOString(); + }, + placesLoad: (state) => { + state.placeList.isLoading = true; + }, + placesLoaded: (state, action: PayloadAction) => { + const timestamp = new Date().toISOString(); + const places = action.payload; + state.placeList = remoteList(places); + state.placeList.loaded = timestamp; + state.placeList.items.forEach((item) => (item.loaded = timestamp)); + }, + statsLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + const statsItem = state.statsByCanvassAssId[canvassAssId]; + + state.statsByCanvassAssId[canvassAssId] = remoteItem(canvassAssId, { + data: statsItem?.data || null, + isLoading: true, + }); + }, + statsLoaded: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignmentStats]> + ) => { + const [canvassAssId, stats] = action.payload; + + state.statsByCanvassAssId[canvassAssId] = remoteItem(canvassAssId, { + data: { id: canvassAssId, ...stats }, + isLoading: false, + isStale: false, + loaded: new Date().toISOString(), + }); + }, + }, +}); + +export default canvassAssignmentSlice; +export const { + assigneeAdd, + assigneeAdded, + assigneeUpdated, + assigneesLoad, + assigneesLoaded, + myAssignmentsLoad, + myAssignmentsLoaded, + canvassAssignmentCreated, + canvassAssignmentLoad, + canvassAssignmentLoaded, + canvassAssignmentUpdated, + canvassAssignmentsLoad, + canvassAssignmentsLoaded, + canvassSessionCreated, + canvassSessionsLoad, + canvassSessionsLoaded, + placeCreated, + placesLoad, + placesLoaded, + placeUpdated, + statsLoad, + statsLoaded, +} = canvassAssignmentSlice.actions; diff --git a/src/features/canvassAssignments/types.ts b/src/features/canvassAssignments/types.ts new file mode 100644 index 000000000..814def005 --- /dev/null +++ b/src/features/canvassAssignments/types.ts @@ -0,0 +1,107 @@ +import { ZetkinArea } from 'features/areas/types'; +import { ZetkinPerson } from 'utils/types/zetkin'; + +export type ZetkinMetric = { + definesDone: boolean; + description: string; + id: string; + kind: 'boolean' | 'scale5'; + question: string; +}; + +export type ZetkinCanvassAssignment = { + campaign: { + id: number; + }; + id: string; + metrics: ZetkinMetric[]; + organization: { + id: number; + }; + title: string | null; +}; + +export type ZetkinCanvassAssignmentPostBody = Partial< + Omit +> & { + campaign_id: number; + metrics: Omit[]; +}; +export type ZetkinCanvassAssignmentPatchbody = Partial< + Omit +>; + +export type Visit = { + canvassAssId: string | null; + id: string; + noteToOfficial: string | null; + responses: { + metricId: string; + response: string; + }[]; + timestamp: string; +}; + +export type Household = { + id: string; + title: string; + visits: Visit[]; +}; + +export type HouseholdPatchBody = Partial>; + +export type ZetkinPlace = { + description: string | null; + households: Household[]; + id: string; + orgId: number; + position: { lat: number; lng: number }; + title: string | null; +}; + +export type ZetkinPlacePostBody = Partial< + Omit +>; + +export type ZetkinPlacePatchBody = Partial< + Omit +> & { + households?: Partial> & + { visits?: Partial>[] }[]; +}; + +export type ZetkinCanvassSession = { + area: ZetkinArea; + assignee: ZetkinPerson; + assignment: { + id: string; + title: string | null; + }; +}; + +export type ZetkinCanvassSessionPostBody = { + areaId: string; + personId: number; +}; + +export type ZetkinCanvassAssignee = { + canvassAssId: string; + id: number; +}; +export type ZetkinCanvassAssigneePatchBody = Partial; + +export type ZetkinCanvassAssignmentStats = { + metrics: { + metric: ZetkinMetric; + values: number[]; + }[]; + num_areas: number; + num_households: number; + num_places: number; + num_successful_visited_households: number; + num_visited_areas: number; + num_visited_households: number; + num_visited_households_outside_areas: number; + num_visited_places: number; + num_visited_places_outside_areas: number; +}; diff --git a/src/features/areas/utils/getCrosshairPositionOnMap.tsx b/src/features/canvassAssignments/utils/getCrosshairPositionOnMap.tsx similarity index 100% rename from src/features/areas/utils/getCrosshairPositionOnMap.tsx rename to src/features/canvassAssignments/utils/getCrosshairPositionOnMap.tsx diff --git a/src/features/areas/utils/isPointInsidePolygon.ts b/src/features/canvassAssignments/utils/isPointInsidePolygon.ts similarity index 100% rename from src/features/areas/utils/isPointInsidePolygon.ts rename to src/features/canvassAssignments/utils/isPointInsidePolygon.ts diff --git a/src/features/areas/utils/isWithinLast24Hours.tsx b/src/features/canvassAssignments/utils/isWithinLast24Hours.tsx similarity index 100% rename from src/features/areas/utils/isWithinLast24Hours.tsx rename to src/features/canvassAssignments/utils/isWithinLast24Hours.tsx diff --git a/src/features/areas/utils/markerIcon.tsx b/src/features/canvassAssignments/utils/markerIcon.tsx similarity index 100% rename from src/features/areas/utils/markerIcon.tsx rename to src/features/canvassAssignments/utils/markerIcon.tsx diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx index ccc68f2a0..727efc1c2 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -22,7 +22,7 @@ export const getServerSideProps: GetServerSideProps = scaffold(async () => { }, scaffoldOptions); const AreasMap = dynamic( - () => import('../../../../features/areas/components/AreasMap'), + () => import('../../../../features/areas/components/AreasMap/index'), { ssr: false } ); 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 6f75d6bb8..7b1a13e2f 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx @@ -1,17 +1,15 @@ import { GetServerSideProps } from 'next'; import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; -import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; -import { ZetkinCanvassSession } from 'features/areas/types'; import { ZetkinPerson } from 'utils/types/zetkin'; import ZUIAvatar from 'zui/ZUIAvatar'; -import { useMessages } from 'core/i18n'; -import messageIds from 'features/areas/l10n/messageIds'; import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; import { AREAS } from 'utils/featureFlags'; +import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; +import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; +import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; const scaffoldOptions = { authLevelRequired: 2, @@ -40,7 +38,6 @@ const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { - const messages = useMessages(messageIds); const allSessions = useCanvassSessions(parseInt(orgId), canvassAssId).data || []; const sessions = allSessions.filter( @@ -81,7 +78,7 @@ const CanvassAssignmentPage: PageWithLayout = ({ { field: 'name', flex: 1, - headerName: messages.canvassAssignment.canvassers.nameColumn(), + headerName: 'Name', valueGetter: (params) => `${params.row.person.first_name} ${params.row.person.last_name}`, }, @@ -90,7 +87,7 @@ const CanvassAssignmentPage: PageWithLayout = ({ field: 'areas', flex: 1, headerAlign: 'left', - headerName: messages.canvassAssignment.canvassers.areasColumn(), + headerName: 'Areas', type: 'number', valueGetter: (params) => params.row.sessions.length, }, diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx new file mode 100644 index 000000000..2e95a8c6d --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/editor.tsx @@ -0,0 +1,285 @@ +import { Close } from '@mui/icons-material'; +import { GetServerSideProps } from 'next'; +import { useState } from 'react'; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Dialog, + IconButton, + Typography, +} from '@mui/material'; + +import ZUIFuture from 'zui/ZUIFuture'; +import MetricCard from 'features/canvassAssignments/components/MetricCard'; +import { AREAS } from 'utils/featureFlags'; +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import useCanvassAssignmentMutations from 'features/canvassAssignments/hooks/useCanvassAssignmentMutations'; +import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; +import { ZetkinMetric } from 'features/canvassAssignments/types'; +import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; + +const scaffoldOptions = { + authLevelRequired: 2, + featuresRequired: [AREAS], +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, canvassAssId } = ctx.params!; + return { + props: { campId, canvassAssId, orgId }, + }; +}, scaffoldOptions); + +interface CanvassAssignmentEditorProps { + orgId: string; + canvassAssId: string; +} + +const CanvassAssignmentEditorPage: PageWithLayout< + CanvassAssignmentEditorProps +> = ({ orgId, canvassAssId }) => { + const updateCanvassAssignment = useCanvassAssignmentMutations( + parseInt(orgId), + canvassAssId + ); + const canvassAssignmentFuture = useCanvassAssignment( + parseInt(orgId), + canvassAssId + ); + + const [metricBeingEdited, setMetricBeingEdited] = + useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [idOfMetricBeingDeleted, setIdOfQuestionBeingDeleted] = useState< + string | null + >(null); + + const handleSaveMetric = async (metric: ZetkinMetric) => { + if (canvassAssignmentFuture.data) { + await updateCanvassAssignment({ + metrics: canvassAssignmentFuture.data.metrics + .map((m) => (m.id === metric.id ? metric : m)) + .concat(metric.id ? [] : [metric]), + }); + } + setMetricBeingEdited(null); + }; + + const handleDeleteMetric = async (id: string) => { + if (canvassAssignmentFuture.data) { + await updateCanvassAssignment({ + metrics: canvassAssignmentFuture.data.metrics.filter( + (m) => m.id !== id + ), + }); + } + setMetricBeingEdited(null); + }; + + const handleAddNewMetric = (kind: 'boolean' | 'scale5') => { + setMetricBeingEdited({ + definesDone: false, + description: '', + id: '', + kind: kind, + question: '', + }); + }; + + return ( + + + {(assignment) => ( + <> + + Here you can configure the questions for your canvass assignment + + + + + + {metricBeingEdited && ( + metric.definesDone + )} + isOnlyQuestion={assignment.metrics.length == 1} + metric={metricBeingEdited} + onClose={() => setMetricBeingEdited(null)} + onDelete={(target: EventTarget & HTMLButtonElement) => { + if (metricBeingEdited.definesDone) { + setIdOfQuestionBeingDeleted(metricBeingEdited.id); + setAnchorEl(target); + setMetricBeingEdited(null); + } else { + handleDeleteMetric(metricBeingEdited.id); + } + }} + onSave={handleSaveMetric} + /> + )} + + {assignment.metrics.length > 0 ? 'Your list of questions:' : ''} + {assignment.metrics.map((metric) => ( + + + + + {metric.definesDone && ( + + This question defines if the mission was successful + + )} + + {metric.question || 'Untitled question'} + + + {metric.description || 'No description'} + + + + + {metric.kind == 'boolean' ? 'Yes/no' : 'Scale'} + + + + + + + + {assignment.metrics.length > 1 && ( + + )} + + + ))} + + setAnchorEl(null)} open={!!anchorEl}> + + + {`Delete "${ + assignment.metrics.find( + (metric) => metric.id == idOfMetricBeingDeleted + )?.question + }"`} + { + setIdOfQuestionBeingDeleted(null); + setAnchorEl(null); + }} + > + + + + + {`If you want to delete "${ + assignment.metrics.find( + (metric) => metric.id == idOfMetricBeingDeleted + )?.question + }" you need to pick another + yes/no-question to be the question that defines if the msision + was successful`} + + + Yes/no questions + {assignment.metrics + .filter( + (metric) => + metric.kind == 'boolean' && + metric.id != idOfMetricBeingDeleted + ) + .map((metric) => ( + + {metric.question} + + + ))} + + + + + )} + + + ); +}; + +CanvassAssignmentEditorPage.getLayout = function getLayout(page) { + return ( + {page} + ); +}; + +export default CanvassAssignmentEditorPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index f0087637c..c43807875 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,21 +1,20 @@ -import { Box, Button, Card, Divider, Typography } from '@mui/material'; +import { Box, Button, Card, Divider, lighten, Typography } from '@mui/material'; import { GetServerSideProps } from 'next'; import { Edit } from '@mui/icons-material'; import { useRouter } from 'next/router'; import { makeStyles, useTheme } from '@mui/styles'; -import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; -import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; -import { Msg } from 'core/i18n'; -import messageIds from 'features/areas/l10n/messageIds'; -import ZUIFutures from 'zui/ZUIFutures'; -import ZUIAnimatedNumber from 'zui/ZUIAnimatedNumber'; -import useCanvassAssignmentStats from 'features/areas/hooks/useCanvassAssignmentStats'; import ZUIStackedStatusBar from 'zui/ZUIStackedStatusBar'; import { getContrastColor } from 'utils/colorUtils'; import { AREAS } from 'utils/featureFlags'; +import { scaffold } from 'utils/next'; +import ZUIFutures from 'zui/ZUIFutures'; +import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; +import useCanvassAssignmentStats from 'features/canvassAssignments/hooks/useCanvassAssignmentStats'; +import ZUIAnimatedNumber from 'zui/ZUIAnimatedNumber'; +import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; +import AssignmentMetricsChart from 'features/canvassAssignments/components/AssignmentMetricsChart'; const scaffoldOptions = { authLevelRequired: 2, @@ -85,9 +84,7 @@ const CanvassAssignmentPage: PageWithLayout = ({ - - - + Areas {!!stats.num_areas && ( {(animatedValue) => ( @@ -104,19 +101,13 @@ const CanvassAssignmentPage: PageWithLayout = ({ startIcon={} variant="text" > - + Edit plan ) : ( - + This assignment has not been planned yet. )} + + {stats.metrics && } + Progress @@ -157,11 +146,11 @@ const CanvassAssignmentPage: PageWithLayout = ({ = ({ = ({ - {`${stats.num_visited_households} logged`} + {`${stats.num_successful_visited_households} success of ${stats.num_visited_households} visits`} diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx index bdd649335..62157ef33 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx @@ -5,16 +5,19 @@ import { GetServerSideProps } from 'next'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; +import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; import useAreas from 'features/areas/hooks/useAreas'; import useServerSide from 'core/useServerSide'; -import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; +import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; import ZUIFuture from 'zui/ZUIFuture'; -import useCreateCanvassSession from 'features/areas/hooks/useCreateCanvassSession'; +import useCreateCanvassSession from 'features/canvassAssignments/hooks/useCreateCanvassSession'; import { AREAS } from 'utils/featureFlags'; const PlanMap = dynamic( - () => import('../../../../../../../features/areas/components/PlanMap'), + () => + import( + '../../../../../../../features/canvassAssignments/components/PlanMap' + ), { ssr: false } ); diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index cd6a721e5..2fb3840b1 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,12 +5,6 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), - assigneesByCanvassAssignmentId: {}, - canvassAssignmentList: remoteList(), - mySessionsList: remoteList(), - placeList: remoteList(), - sessionsByAssignmentId: {}, - statsByCanvassAssId: {}, tagsByAreaId: {}, }, breadcrumbs: { @@ -28,6 +22,14 @@ export default function mockState(overrides?: RootState) { campaignsByOrgId: {}, recentlyCreatedCampaign: null, }, + canvassAssignments: { + assigneesByCanvassAssignmentId: {}, + canvassAssignmentList: remoteList(), + mySessionsList: remoteList(), + placeList: remoteList(), + sessionsByAssignmentId: {}, + statsByCanvassAssId: {}, + }, duplicates: { potentialDuplicatesList: remoteList(), },