diff --git a/.github/workflows/reusable-base.yml b/.github/workflows/reusable-base.yml index 44710bdcdf..cfce07c930 100644 --- a/.github/workflows/reusable-base.yml +++ b/.github/workflows/reusable-base.yml @@ -60,7 +60,7 @@ jobs: - name: Test run: yarn ${{ inputs.yarn-test-script }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() && inputs.upload-path with: path: ${{ inputs.upload-path }} diff --git a/src/app/beta/orgs/[orgId]/areaassignment/[assignmentId]/areas/[areaId]/assignees/[personId]/route.ts b/src/app/beta/orgs/[orgId]/areaassignment/[assignmentId]/areas/[areaId]/assignees/[personId]/route.ts index c6bd028341..02725b93ef 100644 --- a/src/app/beta/orgs/[orgId]/areaassignment/[assignmentId]/areas/[areaId]/assignees/[personId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignment/[assignmentId]/areas/[areaId]/assignees/[personId]/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/canvassAssignments/models'; +import { AreaAssignmentModel } from 'features/areaAssignments/models'; type RouteMeta = { params: { @@ -26,7 +26,7 @@ export async function DELETE(request: NextRequest, { params }: RouteMeta) { apiClient; orgId; - const assignmentModel = await CanvassAssignmentModel.findOne({ + const assignmentModel = await AreaAssignmentModel.findOne({ _id: params.assignmentId, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areasgraph/route.ts similarity index 71% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areasgraph/route.ts index 535ff7bc62..0567c911d2 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areasgraph/route.ts @@ -3,26 +3,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { AreaModel } from 'features/areas/models'; import { - CanvassAssignmentModel, - PlaceModel, -} from 'features/canvassAssignments/models'; + AreaAssignmentModel, + LocationModel, +} from 'features/areaAssignments/models'; import { AreaCardData, AreaGraphData, Household, Visit, - ZetkinCanvassSession, - ZetkinPlace, -} from 'features/canvassAssignments/types'; -import getAreaData from 'features/canvassAssignments/utils/getAreaData'; -import isPointInsidePolygon from 'features/canvassAssignments/utils/isPointInsidePolygon'; + ZetkinAreaAssignmentSession, + ZetkinLocation, +} from 'features/areaAssignments/types'; +import getAreaData from 'features/areaAssignments/utils/getAreaData'; +import isPointInsidePolygon from 'features/canvass/utils/isPointInsidePolygon'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; import { ZetkinPerson } from 'utils/types/zetkin'; import { ZetkinArea } from 'features/areas/types'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; @@ -37,15 +37,15 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ apiClient, orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignmentModel = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const assignmentModel = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, }).lean(); if (!assignmentModel) { return new NextResponse(null, { status: 404 }); } - const sessions: ZetkinCanvassSession[] = []; + const sessions: ZetkinAreaAssignmentSession[] = []; for await (const sessionData of assignmentModel.sessions) { const person = await apiClient.get( @@ -92,8 +92,8 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { ...new Map(areas.map((area) => [area.id, area])).values(), ]; - const allPlaceModels = await PlaceModel.find({ orgId }).lean(); - const allPlaces: ZetkinPlace[] = allPlaceModels.map((model) => ({ + const allLocationModels = await LocationModel.find({ orgId }).lean(); + const allLocations: ZetkinLocation[] = allLocationModels.map((model) => ({ description: model.description, households: model.households, id: model._id.toString(), @@ -102,19 +102,19 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { title: model.title, })); - type PlaceWithAreaId = ZetkinPlace & { areaId: ZetkinArea['id'] }; + type LocationWithAreaId = ZetkinLocation & { areaId: ZetkinArea['id'] }; - //Find places in the given areas - const placesInAreas: PlaceWithAreaId[] = []; + //Find locations in the given areas + const locationsInAreas: LocationWithAreaId[] = []; uniqueAreas.forEach((area) => { - allPlaces.forEach((place) => { - const placeIsInArea = isPointInsidePolygon( - { lat: place.position.lat, lng: place.position.lng }, + allLocations.forEach((location) => { + const locationIsInArea = isPointInsidePolygon( + { lat: location.position.lat, lng: location.position.lng }, area.points.map((point) => ({ lat: point[0], lng: point[1] })) ); - if (placeIsInArea) { - placesInAreas.push({ ...place, areaId: area.id }); + if (locationIsInArea) { + locationsInAreas.push({ ...location, areaId: area.id }); } }); }); @@ -127,12 +127,12 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { let firstVisit: Date = new Date(); let lastVisit: Date = new Date(); - allPlaces.forEach((place) => { - const placeVisits = place.households + allLocations.forEach((location) => { + const locationVisits = location.households .flatMap((household) => household.visits) - .filter((visit) => visit.canvassAssId === params.canvassAssId); + .filter((visit) => visit.areaAssId === params.areaAssId); - filteredVisitsInAllAreas.push(...placeVisits); + filteredVisitsInAllAreas.push(...locationVisits); }); if (filteredVisitsInAllAreas.length > 0) { @@ -167,18 +167,20 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areaVisitsData: AreaGraphData[] = []; const householdList: Household[] = []; - allPlaces.forEach((place) => { - const placeIsInArea = isPointInsidePolygon( - { lat: place.position.lat, lng: place.position.lng }, + allLocations.forEach((location) => { + const locationIsInArea = isPointInsidePolygon( + { lat: location.position.lat, lng: location.position.lng }, area.points.map((point) => ({ lat: point[0], lng: point[1] })) ); - if (placeIsInArea) { - const filteredHouseholds = place.households.filter((household) => { - return household.visits.filter( - (visit) => visit.canvassAssId === params.canvassAssId - ); - }); + if (locationIsInArea) { + const filteredHouseholds = location.households.filter( + (household) => { + return household.visits.filter( + (visit) => visit.areaAssId === params.areaAssId + ); + } + ); householdList.push(...filteredHouseholds); } }); @@ -200,17 +202,17 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }); //Visits outside assigned areas logic - const idsOfPlacesInAreas = new Set( - placesInAreas.map((place) => place.id) + const idsOfLocationsInAreas = new Set( + locationsInAreas.map((location) => location.id) ); - const placesOutsideAreas = allPlaces.filter( - (place) => !idsOfPlacesInAreas.has(place.id) + const locationsOutsideAreas = allLocations.filter( + (location) => !idsOfLocationsInAreas.has(location.id) ); - placesOutsideAreas.forEach((place) => { - place.households.forEach((household) => { + locationsOutsideAreas.forEach((location) => { + location.households.forEach((household) => { household.visits.forEach((visit) => { - if (visit.canvassAssId == params.canvassAssId) { + if (visit.areaAssId == params.areaAssId) { householdsOutsideAreasList.push(household); } }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areastats/route.ts similarity index 72% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areastats/route.ts index e5e24aa3f9..95db508bfc 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/areastats/route.ts @@ -3,24 +3,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { AreaModel } from 'features/areas/models'; import { - CanvassAssignmentModel, - PlaceModel, - PlaceVisitModel, - PlaceVisitModelType, -} from 'features/canvassAssignments/models'; + AreaAssignmentModel, + LocationModel, + LocationVisitModel, + LocationVisitModelType, +} from 'features/areaAssignments/models'; import { ZetkinAssignmentAreaStatsItem, - ZetkinCanvassSession, - ZetkinPlace, -} from 'features/canvassAssignments/types'; -import isPointInsidePolygon from 'features/canvassAssignments/utils/isPointInsidePolygon'; + ZetkinAreaAssignmentSession, + ZetkinLocation, +} from 'features/areaAssignments/types'; +import isPointInsidePolygon from 'features/canvass/utils/isPointInsidePolygon'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; import { ZetkinPerson, ZetkinTag } from 'utils/types/zetkin'; import { ZetkinArea } from 'features/areas/types'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; @@ -38,20 +38,20 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { //Get all areas const allAreaModels = await AreaModel.find({ orgId }); - //Get all places - const allPlaceModels = await PlaceModel.find({ orgId }); + //Get all locations + const allLocationModels = await LocationModel.find({ orgId }); //Get the assignment - const assignmentModel = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const assignmentModel = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, }); - if (!assignmentModel || !allPlaceModels || !allAreaModels) { + if (!assignmentModel || !allLocationModels || !allAreaModels) { return new NextResponse(null, { status: 404 }); } //Find all sessions of the assignment - const sessions: ZetkinCanvassSession[] = []; + const sessions: ZetkinAreaAssignmentSession[] = []; for await (const sessionData of assignmentModel.sessions) { const person = await apiClient.get( @@ -107,7 +107,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { `/api/orgs/${orgId}/people/tags` ); - const allPlaces: ZetkinPlace[] = allPlaceModels.map((model) => ({ + const allLocations: ZetkinLocation[] = allLocationModels.map((model) => ({ description: model.description, households: model.households, id: model._id.toString(), @@ -146,25 +146,25 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areasWithVisitsButNoAssignees: ZetkinArea[] = []; areasWithoutAssignees.forEach((area) => { - allPlaces.forEach((place) => { - const placeIsInArea = isPointInsidePolygon( - { lat: place.position.lat, lng: place.position.lng }, + allLocations.forEach((location) => { + const locationIsInArea = isPointInsidePolygon( + { lat: location.position.lat, lng: location.position.lng }, area.points.map((point) => ({ lat: point[0], lng: point[1] })) ); - if (placeIsInArea) { - let placeHasVisits = false; - place.households.forEach((household) => { + if (locationIsInArea) { + let locationHasVisits = false; + location.households.forEach((household) => { const hasVisitInThisAssignment = household.visits.find( - (visit) => visit.canvassAssId == params.canvassAssId + (visit) => visit.areaAssId == params.areaAssId ); - if (hasVisitInThisAssignment && !placeHasVisits) { - placeHasVisits = true; + if (hasVisitInThisAssignment && !locationHasVisits) { + locationHasVisits = true; } }); - if (placeHasVisits) { + if (locationHasVisits) { areasWithVisitsButNoAssignees.push(area); } } @@ -185,31 +185,31 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const statsByAreaId: Record = {}; - const allPlaceVisits = await PlaceVisitModel.find({ - canvassAssId: params.canvassAssId, + const allLocationVisits = await LocationVisitModel.find({ + areaAssId: params.areaAssId, }); - const visitsByPlaceId: Record = {}; - allPlaceVisits.forEach((visit) => { - if (!visitsByPlaceId[visit.placeId]) { - visitsByPlaceId[visit.placeId] = []; + const visitsByLocationId: Record = {}; + allLocationVisits.forEach((visit) => { + if (!visitsByLocationId[visit.locationId]) { + visitsByLocationId[visit.locationId] = []; } - visitsByPlaceId[visit.placeId].push(visit); + visitsByLocationId[visit.locationId].push(visit); }); uniqueAreas.forEach((area) => { statsByAreaId[area.id] = { areaId: area.id, num_households: 0, - num_places: 0, + num_locations: 0, num_successful_visited_households: 0, num_visited_households: 0, - num_visited_places: 0, + num_visited_locations: 0, }; - allPlaces.forEach((place) => { - const placeIsInArea = isPointInsidePolygon( - { lat: place.position.lat, lng: place.position.lng }, + allLocations.forEach((location) => { + const locationIsInArea = isPointInsidePolygon( + { lat: location.position.lat, lng: location.position.lng }, area.points.map((point) => ({ lat: point[0], lng: point[1] })) ); @@ -218,28 +218,28 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { (metric) => metric.definesDone )?._id; - if (placeIsInArea) { - statsByAreaId[area.id].num_places++; - statsByAreaId[area.id].num_households += place.households.length; + if (locationIsInArea) { + statsByAreaId[area.id].num_locations++; + statsByAreaId[area.id].num_households += location.households.length; - let placeVisited = false; + let locationVisited = false; - place.households.forEach((household) => { + location.households.forEach((household) => { const hasVisitInThisAssignment = household.visits.find( - (visit) => visit.canvassAssId == params.canvassAssId + (visit) => visit.areaAssId == params.areaAssId ); if (hasVisitInThisAssignment) { statsByAreaId[area.id].num_visited_households++; - if (!placeVisited) { - statsByAreaId[area.id].num_visited_places++; - placeVisited = true; + if (!locationVisited) { + statsByAreaId[area.id].num_visited_locations++; + locationVisited = true; } } household.visits.forEach((visit) => { - if (visit.canvassAssId == params.canvassAssId) { + if (visit.areaAssId == params.areaAssId) { visit.responses.forEach((response) => { if (response.metricId == idOfMetricThatDefinesDone) { if (response.response == 'yes') { @@ -252,8 +252,8 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }); }); - const placeVisits = visitsByPlaceId[place.id] || []; - placeVisits.forEach((visit) => { + const locationVisits = visitsByLocationId[location.id] || []; + locationVisits.forEach((visit) => { const numHouseholds = Math.max( ...visit.responses.map((response) => response.responseCounts.reduce((sum, count) => sum + count, 0) @@ -268,7 +268,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { statsByAreaId[area.id].num_successful_visited_households += numSuccessful; statsByAreaId[area.id].num_visited_households += numHouseholds; - statsByAreaId[area.id].num_visited_places += 1; + statsByAreaId[area.id].num_visited_locations += 1; }); } }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/route.ts similarity index 74% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/route.ts index 62c600e675..cbf840286c 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/route.ts @@ -2,15 +2,15 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; +import { AreaAssignmentModel } from 'features/areaAssignments/models'; import { - ZetkinCanvassAssignment, + ZetkinAreaAssignment, ZetkinMetric, -} from 'features/canvassAssignments/types'; +} from 'features/areaAssignments/types'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; @@ -25,21 +25,21 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const canvassAssignmentModel = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const areaAssignmentModel = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, orgId, }); - if (!canvassAssignmentModel) { + if (!areaAssignmentModel) { return new NextResponse(null, { status: 404 }); } - const canvassAssignment: ZetkinCanvassAssignment = { - campaign: { id: canvassAssignmentModel.campId }, - end_date: canvassAssignmentModel.end_date, - id: canvassAssignmentModel._id.toString(), - instructions: canvassAssignmentModel.instructions, - metrics: (canvassAssignmentModel.metrics || []).map((metric) => ({ + const areaAssignment: ZetkinAreaAssignment = { + campaign: { id: areaAssignmentModel.campId }, + end_date: areaAssignmentModel.end_date, + id: areaAssignmentModel._id.toString(), + instructions: areaAssignmentModel.instructions, + metrics: (areaAssignmentModel.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, description: metric.description || '', id: metric._id, @@ -47,12 +47,12 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { question: metric.question, })), organization: { id: orgId }, - reporting_level: canvassAssignmentModel.reporting_level || 'household', - start_date: canvassAssignmentModel.start_date, - title: canvassAssignmentModel.title, + reporting_level: areaAssignmentModel.reporting_level || 'household', + start_date: areaAssignmentModel.start_date, + title: areaAssignmentModel.title, }; - return Response.json({ data: canvassAssignment }); + return Response.json({ data: areaAssignment }); } ); } @@ -79,8 +79,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { if (newMetrics) { // Find existing metrics to remove - const assignment = await CanvassAssignmentModel.findById( - params.canvassAssId + const assignment = await AreaAssignmentModel.findById( + params.areaAssId ).select('metrics'); if (!assignment) { @@ -98,8 +98,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { // Remove metrics that are no longer included if (metricsToDelete.length > 0) { - await CanvassAssignmentModel.updateOne( - { _id: params.canvassAssId }, + await AreaAssignmentModel.updateOne( + { _id: params.areaAssId }, { $pull: { metrics: { _id: { $in: metricsToDelete } } } } ); } @@ -107,8 +107,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { 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 }, + await AreaAssignmentModel.updateOne( + { _id: params.areaAssId, 'metrics._id': metric.id }, { $set: { 'metrics.$.definesDone': metric.definesDone, @@ -120,8 +120,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { ); } else { // If no ID exists, push it as a new metric - await CanvassAssignmentModel.updateOne( - { _id: params.canvassAssId }, + await AreaAssignmentModel.updateOne( + { _id: params.areaAssId }, { $push: { metrics: metric }, } @@ -131,7 +131,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { } type UpdateFieldsType = Partial< Pick< - ZetkinCanvassAssignment, + ZetkinAreaAssignment, | 'title' | 'start_date' | 'end_date' @@ -163,13 +163,13 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { } if (Object.keys(updateFields).length > 0) { - await CanvassAssignmentModel.updateOne( - { _id: params.canvassAssId }, + await AreaAssignmentModel.updateOne( + { _id: params.areaAssId }, { $set: updateFields } ); } - const model = await CanvassAssignmentModel.findById( - params.canvassAssId + const model = await AreaAssignmentModel.findById( + params.areaAssId ).populate('metrics'); if (!model) { @@ -209,8 +209,8 @@ export async function DELETE(request: NextRequest, { params }: RouteMeta) { async ({ orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const result = await CanvassAssignmentModel.findOneAndDelete({ - _id: params.canvassAssId, + const result = await AreaAssignmentModel.findOneAndDelete({ + _id: params.areaAssId, orgId: orgId, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/sessions/route.ts similarity index 90% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/sessions/route.ts index 5515312c37..bfc8682c8a 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/sessions/route.ts @@ -1,15 +1,15 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; -import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; +import { AreaAssignmentModel } from 'features/areaAssignments/models'; +import { ZetkinAreaAssignmentSession } from 'features/areaAssignments/types'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; import { ZetkinPerson } from 'utils/types/zetkin'; import { AreaModel } from 'features/areas/models'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; @@ -24,15 +24,15 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ apiClient, orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const model = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const model = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, }); if (!model) { return new NextResponse(null, { status: 404 }); } - const sessions: ZetkinCanvassSession[] = []; + const sessions: ZetkinAreaAssignmentSession[] = []; for await (const sessionData of model.sessions) { let person: ZetkinPerson | null; @@ -102,8 +102,8 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { async ({ apiClient, orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignment = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const assignment = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, }); if (!assignment) { diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/stats/route.ts similarity index 69% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/stats/route.ts index 5a72906139..3b98c6aeb2 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/stats/route.ts @@ -3,24 +3,24 @@ 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 isPointInsidePolygon from 'features/canvass/utils/isPointInsidePolygon'; import { - CanvassAssignmentModel, - PlaceModel, -} from 'features/canvassAssignments/models'; + AreaAssignmentModel, + LocationModel, +} from 'features/areaAssignments/models'; import { Household, Visit, - ZetkinCanvassAssignmentStats, - ZetkinCanvassSession, - ZetkinPlace, -} from 'features/canvassAssignments/types'; + ZetkinAreaAssignmentStats, + ZetkinAreaAssignmentSession, + ZetkinLocation, +} from 'features/areaAssignments/types'; import { AreaModel } from 'features/areas/models'; import { ZetkinArea } from 'features/areas/types'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; @@ -36,15 +36,15 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { await mongoose.connect(process.env.MONGODB_URL || ''); //Find all sessions of the assignment - const assignmentModel = await CanvassAssignmentModel.findOne({ - _id: params.canvassAssId, + const assignmentModel = await AreaAssignmentModel.findOne({ + _id: params.areaAssId, }); if (!assignmentModel) { return new NextResponse(null, { status: 404 }); } - const sessions: ZetkinCanvassSession[] = []; + const sessions: ZetkinAreaAssignmentSession[] = []; for await (const sessionData of assignmentModel.sessions) { const person = await apiClient.get( @@ -98,10 +98,10 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { ...new Map(areas.map((area) => [area['id'], area])).values(), ]; - //Get all places - const allPlaceModels = await PlaceModel.find({ orgId }); + //Get all locations + const allLocationModels = await LocationModel.find({ orgId }); - const allPlaces: ZetkinPlace[] = allPlaceModels.map((model) => ({ + const allLocations: ZetkinLocation[] = allLocationModels.map((model) => ({ description: model.description, households: model.households, id: model._id.toString(), @@ -110,31 +110,33 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { title: model.title, })); - type PlaceWithAreaId = ZetkinPlace & { areaId: ZetkinArea['id'] }; + type LocationWithAreaId = ZetkinLocation & { areaId: ZetkinArea['id'] }; - //Find places in the given areas - const placesInAreas: PlaceWithAreaId[] = []; + //Find locations in the given areas + const locationsInAreas: LocationWithAreaId[] = []; uniqueAreas.forEach((area) => { - allPlaces.forEach((place) => { - const placeIsInArea = isPointInsidePolygon( - { lat: place.position.lat, lng: place.position.lng }, + allLocations.forEach((location) => { + const locationIsInArea = isPointInsidePolygon( + { lat: location.position.lat, lng: location.position.lng }, area.points.map((point) => ({ lat: point[0], lng: point[1] })) ); - if (placeIsInArea) { - placesInAreas.push({ ...place, areaId: area.id }); + if (locationIsInArea) { + locationsInAreas.push({ ...location, areaId: area.id }); } }); }); /**https://yagisanatode.com/get-a-unique-list-of-objects-in-an-array-of-object-in-javascript/ */ - const uniquePlacesInAreas = [ - ...new Map(placesInAreas.map((place) => [place['id'], place])).values(), + const uniqueLocationsInAreas = [ + ...new Map( + locationsInAreas.map((location) => [location['id'], location]) + ).values(), ]; const visitsInAreas: Visit[] = []; const successfulVisitsInAreas: Visit[] = []; - const visitedPlacesInAreas: string[] = []; + const visitedLocationsInAreas: string[] = []; const visitedAreas: string[] = []; const householdsInAreas: Household[] = []; @@ -142,7 +144,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const idOfMetricThatDefinesDone = configuredMetrics.find( (metric) => metric.definesDone )?._id; - const accumulatedMetrics: ZetkinCanvassAssignmentStats['metrics'] = + const accumulatedMetrics: ZetkinAreaAssignmentStats['metrics'] = configuredMetrics.map((metric) => ({ metric: { definesDone: metric.definesDone, @@ -154,10 +156,10 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { values: metric.kind == 'boolean' ? [0, 0] : [0, 0, 0, 0, 0], })); - allPlaces.forEach((place) => { - place.households.forEach((household) => { + allLocations.forEach((location) => { + location.households.forEach((household) => { household.visits.forEach((visit) => { - if (visit.canvassAssId == params.canvassAssId) { + if (visit.areaAssId == params.areaAssId) { visit.responses.forEach((response) => { const configuredMetric = configuredMetrics.find( (candidate) => candidate._id == response.metricId @@ -186,13 +188,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }); }); - uniquePlacesInAreas.forEach((place) => { - householdsInAreas.push(...place.households); - place.households.forEach((household) => { + uniqueLocationsInAreas.forEach((location) => { + householdsInAreas.push(...location.households); + location.households.forEach((household) => { household.visits.forEach((visit) => { - if (visit.canvassAssId == params.canvassAssId) { - visitedAreas.push(place.areaId); - visitedPlacesInAreas.push(place.id); + if (visit.areaAssId == params.areaAssId) { + visitedAreas.push(location.areaId); + visitedLocationsInAreas.push(location.id); visitsInAreas.push(visit); visit.responses.forEach((response) => { @@ -207,21 +209,21 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }); }); - //Find places outside the areas that have visits in this assignment - const idsOfPlacesInAreas = new Set( - placesInAreas.map((place) => place.id) + //Find locations outside the areas that have visits in this assignment + const idsOfLocationsInAreas = new Set( + locationsInAreas.map((location) => location.id) ); - const placesOutsideAreas = allPlaces.filter( - (place) => !idsOfPlacesInAreas.has(place.id) + const locationsOutsideAreas = allLocations.filter( + (location) => !idsOfLocationsInAreas.has(location.id) ); const visitsOutsideAreas: Visit[] = []; - const visitedPlacesOutsideAreas: string[] = []; - placesOutsideAreas.forEach((place) => { - place.households.forEach((household) => { + const visitedLocationsOutsideAreas: string[] = []; + locationsOutsideAreas.forEach((location) => { + location.households.forEach((household) => { household.visits.forEach((visit) => { - if (visit.canvassAssId == params.canvassAssId) { - visitedPlacesOutsideAreas.push(place.id); + if (visit.areaAssId == params.areaAssId) { + visitedLocationsOutsideAreas.push(location.id); visitsOutsideAreas.push(visit); } }); @@ -233,14 +235,15 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { metrics: accumulatedMetrics, num_areas: uniqueAreas.length, num_households: householdsInAreas.length, - num_places: uniquePlacesInAreas.length, + num_locations: uniqueLocationsInAreas.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, - num_visited_places: Array.from(new Set(visitedPlacesInAreas)).length, - num_visited_places_outside_areas: Array.from( - new Set(visitedPlacesOutsideAreas) + num_visited_locations: Array.from(new Set(visitedLocationsInAreas)) + .length, + num_visited_locations_outside_areas: Array.from( + new Set(visitedLocationsOutsideAreas) ).length, }, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/visits/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/visits/route.ts similarity index 64% rename from src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/visits/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/visits/route.ts index c87e3ee921..83829bcdef 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/visits/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/[areaAssId]/visits/route.ts @@ -2,21 +2,21 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import { - CanvassAssignmentModel, - PlaceVisitModel, -} from 'features/canvassAssignments/models'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; + AreaAssignmentModel, + LocationVisitModel, +} from 'features/areaAssignments/models'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { - canvassAssId: string; + areaAssId: string; orgId: string; }; }; export async function GET(request: NextRequest, { params }: RouteMeta) { - const canvassAssId = params.canvassAssId; - return asCanvasserAuthorized( + const areaAssId = params.areaAssId; + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -24,8 +24,8 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignmentModel = await CanvassAssignmentModel.find({ - _id: canvassAssId, + const assignmentModel = await AreaAssignmentModel.find({ + _id: areaAssId, orgId: orgId, }); @@ -33,16 +33,16 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ error: {} }, { status: 404 }); } - const visitModels = await PlaceVisitModel.find({ - canvassAssId: canvassAssId, + const visitModels = await LocationVisitModel.find({ + areaAssId: areaAssId, }); return NextResponse.json({ data: visitModels.map((model) => ({ - canvassAssId: model.canvassAssId, + areaAssId: model.areaAssId, id: model._id.toString(), + locationId: model.locationId, personId: model.personId, - placeId: model.placeId, responses: model.responses, timestamp: model.timestamp, })), @@ -52,8 +52,8 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { } export async function POST(request: NextRequest, { params }: RouteMeta) { - const canvassAssId = params.canvassAssId; - return asCanvasserAuthorized( + const areaAssId = params.areaAssId; + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -61,8 +61,8 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { async ({ orgId, personId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignmentModel = await CanvassAssignmentModel.find({ - _id: canvassAssId, + const assignmentModel = await AreaAssignmentModel.find({ + _id: areaAssId, orgId: orgId, }); @@ -72,10 +72,10 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const visit = new PlaceVisitModel({ - canvassAssId, + const visit = new LocationVisitModel({ + areaAssId, + locationId: payload.locationId, personId: personId, - placeId: payload.placeId, responses: payload.responses, timestamp: new Date().toISOString(), }); @@ -84,10 +84,10 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { - canvassAssId: visit.canvassAssId, + areaAssId: visit.areaAssId, id: visit._id.toString(), + locationId: visit.locationId, personId: visit.personId, - placeId: visit.placeId, responses: visit.responses, timestamp: visit.timestamp, }, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/areaassignments/route.ts similarity index 92% rename from src/app/beta/orgs/[orgId]/canvassassignments/route.ts rename to src/app/beta/orgs/[orgId]/areaassignments/route.ts index a5e19f7c4a..a68b868da6 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/areaassignments/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/canvassAssignments/models'; +import { AreaAssignmentModel } from 'features/areaAssignments/models'; type RouteMeta = { params: { @@ -20,7 +20,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignments = CanvassAssignmentModel.find({ orgId: orgId }); + const assignments = AreaAssignmentModel.find({ orgId: orgId }); return NextResponse.json({ data: (await assignments).map((assignment) => ({ @@ -60,7 +60,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = new CanvassAssignmentModel({ + const model = new AreaAssignmentModel({ campId: payload.campaign_id, instructions: payload.instructions, metrics: payload.metrics || [], diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/route.ts similarity index 78% rename from src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts rename to src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/route.ts index 96d4cbb163..bb1763c3b5 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/route.ts +++ b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/route.ts @@ -1,19 +1,19 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/canvassAssignments/models'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; +import { LocationModel } from 'features/areaAssignments/models'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { householdId: string; + locationId: string; orgId: string; - placeId: string; }; }; export async function PATCH(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -23,8 +23,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = await PlaceModel.findOneAndUpdate( - { _id: params.placeId, orgId }, + const model = await LocationModel.findOneAndUpdate( + { _id: params.locationId, orgId }, { $set: { 'households.$[elem].floor': payload.floor, diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/visits/route.ts similarity index 80% rename from src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts rename to src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/visits/route.ts index de6e983dfa..d1d3744de7 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/[householdId]/visits/route.ts +++ b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/[householdId]/visits/route.ts @@ -1,19 +1,19 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/canvassAssignments/models'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; +import { LocationModel } from 'features/areaAssignments/models'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { householdId: string; + locationId: string; orgId: string; - placeId: string; }; }; export async function POST(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -23,12 +23,12 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = await PlaceModel.findOneAndUpdate( - { _id: params.placeId, orgId }, + const model = await LocationModel.findOneAndUpdate( + { _id: params.locationId, orgId }, { $push: { 'households.$[elem].visits': { - canvassAssId: payload.canvassAssId, + areaAssId: payload.areaAssId, doorWasOpened: payload.doorWasOpened, id: new mongoose.Types.ObjectId().toString(), missionAccomplished: payload.missionAccomplished, diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/route.ts similarity index 79% rename from src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts rename to src/app/beta/orgs/[orgId]/locations/[locationId]/households/route.ts index 6d5bdbeeb4..95bc02f8c6 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/households/route.ts +++ b/src/app/beta/orgs/[orgId]/locations/[locationId]/households/route.ts @@ -1,18 +1,18 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/canvassAssignments/models'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; +import { LocationModel } from 'features/areaAssignments/models'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { + locationId: string; orgId: string; - placeId: string; }; }; export async function POST(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -22,8 +22,8 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = await PlaceModel.findOneAndUpdate( - { _id: params.placeId, orgId }, + const model = await LocationModel.findOneAndUpdate( + { _id: params.locationId, orgId }, { $push: { households: { diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts b/src/app/beta/orgs/[orgId]/locations/[locationId]/route.ts similarity index 76% rename from src/app/beta/orgs/[orgId]/places/[placeId]/route.ts rename to src/app/beta/orgs/[orgId]/locations/[locationId]/route.ts index 3770cf9fd3..a097c8ffbb 100644 --- a/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts +++ b/src/app/beta/orgs/[orgId]/locations/[locationId]/route.ts @@ -1,18 +1,18 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { PlaceModel } from 'features/canvassAssignments/models'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; +import { LocationModel } from 'features/areaAssignments/models'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { + locationId: string; orgId: string; - placeId: string; }; }; export async function PATCH(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -22,8 +22,8 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = await PlaceModel.findOneAndUpdate( - { _id: params.placeId, orgId }, + const model = await LocationModel.findOneAndUpdate( + { _id: params.locationId, orgId }, { description: payload.description, households: payload.households, diff --git a/src/app/beta/orgs/[orgId]/places/route.ts b/src/app/beta/orgs/[orgId]/locations/route.ts similarity index 72% rename from src/app/beta/orgs/[orgId]/places/route.ts rename to src/app/beta/orgs/[orgId]/locations/route.ts index ed77bba8d3..3f9026b7a9 100644 --- a/src/app/beta/orgs/[orgId]/places/route.ts +++ b/src/app/beta/orgs/[orgId]/locations/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import mongoose from 'mongoose'; -import { PlaceModel } from 'features/canvassAssignments/models'; -import { ZetkinPlace } from 'features/canvassAssignments/types'; -import asCanvasserAuthorized from 'features/canvassAssignments/utils/asCanvasserAuthorized'; +import { LocationModel } from 'features/areaAssignments/models'; +import { ZetkinLocation } from 'features/areaAssignments/types'; +import asAreaAssigneeAuthorized from 'features/canvass/utils/asAreaAssigneeAuthorized'; type RouteMeta = { params: { @@ -12,7 +12,7 @@ type RouteMeta = { }; export async function GET(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -20,8 +20,8 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { async ({ orgId }) => { await mongoose.connect(process.env.MONGODB_URL || ''); - const placeModels = await PlaceModel.find({ orgId }); - const places: ZetkinPlace[] = placeModels.map((model) => ({ + const locationModels = await LocationModel.find({ orgId }); + const locations: ZetkinLocation[] = locationModels.map((model) => ({ description: model.description, households: model.households, id: model._id.toString(), @@ -30,13 +30,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { title: model.title, })); - return Response.json({ data: places }); + return Response.json({ data: locations }); } ); } export async function POST(request: NextRequest, { params }: RouteMeta) { - return asCanvasserAuthorized( + return asAreaAssigneeAuthorized( { orgId: params.orgId, request: request, @@ -46,7 +46,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = new PlaceModel({ + const model = new LocationModel({ description: payload.description, households: [], orgId: orgId, diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/areaassignments/route.ts similarity index 95% rename from src/app/beta/users/me/canvassassignments/route.ts rename to src/app/beta/users/me/areaassignments/route.ts index 0612acb874..f08b6ee3e2 100644 --- a/src/app/beta/users/me/canvassassignments/route.ts +++ b/src/app/beta/users/me/areaassignments/route.ts @@ -3,8 +3,8 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; -import { AssignmentWithAreas } from 'features/canvassAssignments/types'; +import { AreaAssignmentModel } from 'features/areaAssignments/models'; +import { AssignmentWithAreas } from 'features/canvass/types'; import { ZetkinMembership } from 'utils/types/zetkin'; import { AreaModel } from 'features/areas/models'; import { ZetkinArea } from 'features/areas/types'; @@ -26,7 +26,7 @@ export async function GET(request: NextRequest) { const { id: personId } = membership.profile; //plocka ut alla assignments som personen har en tilldelning i - const assignmentModels = await CanvassAssignmentModel.find({ + const assignmentModels = await AreaAssignmentModel.find({ orgId: membership.organization.id, 'sessions.personId': { $eq: personId }, }); diff --git a/src/app/canvass/[canvassAssId]/map/page.tsx b/src/app/canvass/[areaAssId]/map/page.tsx similarity index 68% rename from src/app/canvass/[canvassAssId]/map/page.tsx rename to src/app/canvass/[areaAssId]/map/page.tsx index 3f5dcaae25..b15a71ded8 100644 --- a/src/app/canvass/[canvassAssId]/map/page.tsx +++ b/src/app/canvass/[areaAssId]/map/page.tsx @@ -4,16 +4,16 @@ import { redirect } from 'next/navigation'; import BackendApiClient from 'core/api/client/BackendApiClient'; import { ZetkinOrganization } from 'utils/types/zetkin'; -import MyCanvassAssignmentPage from 'features/canvassAssignments/components/MyCanvassAssignmentPage'; +import CanvassPage from 'features/canvass/components/CanvassPage'; interface PageProps { params: { - canvassAssId: string; + areaAssId: string; }; } export default async function Page({ params }: PageProps) { - const { canvassAssId } = params; + const { areaAssId } = params; const headersList = headers(); const headersEntries = headersList.entries(); const headersObject = Object.fromEntries(headersEntries); @@ -22,8 +22,8 @@ export default async function Page({ params }: PageProps) { try { await apiClient.get(`/api/users/me`); - return ; + return ; } catch (err) { - return redirect(`/login?redirect=/canvass/${canvassAssId}/map`); + return redirect(`/login?redirect=/canvass/${areaAssId}/map`); } } diff --git a/src/app/canvass/[canvassAssId]/page.tsx b/src/app/canvass/[areaAssId]/page.tsx similarity index 67% rename from src/app/canvass/[canvassAssId]/page.tsx rename to src/app/canvass/[areaAssId]/page.tsx index 7a575d9767..888d2a8e73 100644 --- a/src/app/canvass/[canvassAssId]/page.tsx +++ b/src/app/canvass/[areaAssId]/page.tsx @@ -3,17 +3,17 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import MyCanvassInstructionsPage from 'features/canvassAssignments/components/MyCanvassInstructionsPage'; import { ZetkinOrganization } from 'utils/types/zetkin'; +import CanvassInstructionsPage from 'features/canvass/components/CanvassInstructionsPage'; interface PageProps { params: { - canvassAssId: string; + areaAssId: string; }; } export default async function Page({ params }: PageProps) { - const { canvassAssId } = params; + const { areaAssId } = params; const headersList = headers(); const headersEntries = headersList.entries(); const headersObject = Object.fromEntries(headersEntries); @@ -22,8 +22,8 @@ export default async function Page({ params }: PageProps) { try { await apiClient.get(`/api/users/me`); - return ; + return ; } catch (err) { - return redirect(`/login?redirect=/canvass/${canvassAssId}`); + return redirect(`/login?redirect=/canvass/${areaAssId}`); } } diff --git a/src/core/rpc/index.ts b/src/core/rpc/index.ts index 23daa7a1d1..d4247a2c13 100644 --- a/src/core/rpc/index.ts +++ b/src/core/rpc/index.ts @@ -20,7 +20,7 @@ import { getEmailInsightsDef } from 'features/emails/rpc/getEmailInsights'; import { renderEmailDef } from 'features/emails/rpc/renderEmail/server'; import { createCallAssignmentDef } from 'features/callAssignments/rpc/createCallAssignment'; import { getJoinFormEmbedDataDef } from 'features/joinForms/rpc/getJoinFormEmbedData'; -import { createHouseholdsDef } from 'features/canvassAssignments/rpc/createHouseholds/server'; +import { createHouseholdsDef } from 'features/canvass/rpc/createHouseholds/server'; import { getAllEventsDef } from 'features/events/rpc/getAllEvents'; export function createRPCRouter() { diff --git a/src/core/store.ts b/src/core/store.ts index b0c49058d9..dfa54a7503 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -50,17 +50,19 @@ import tasksSlice, { TasksStoreSlice } from 'features/tasks/store'; import userSlice, { UserStoreSlice } from 'features/user/store'; import viewsSlice, { ViewsStoreSlice } from 'features/views/store'; import areasSlice, { AreasStoreSlice } from 'features/areas/store'; -import canvassAssignmentSlice, { - canvassAssignmentCreated, - CanvassAssignmentsStoreSlice, -} from 'features/canvassAssignments/store'; +import areaAssignmentSlice, { + areaAssignmentCreated, + AreaAssignmentsStoreSlice, +} from 'features/areaAssignments/store'; +import canvassSlice, { CanvassStoreSlice } from 'features/canvass/store'; export interface RootState { + areaAssignments: AreaAssignmentsStoreSlice; areas: AreasStoreSlice; breadcrumbs: BreadcrumbsStoreSlice; callAssignments: CallAssignmentSlice; - canvassAssignments: CanvassAssignmentsStoreSlice; campaigns: CampaignsStoreSlice; + canvass: CanvassStoreSlice; duplicates: PotentialDuplicatesStoreSlice; emails: EmailStoreSlice; events: EventsStoreSlice; @@ -81,11 +83,12 @@ export interface RootState { } const reducer = { + areaAssignments: areaAssignmentSlice.reducer, areas: areasSlice.reducer, breadcrumbs: breadcrumbsSlice.reducer, callAssignments: callAssignmentsSlice.reducer, campaigns: campaignsSlice.reducer, - canvassAssignments: canvassAssignmentSlice.reducer, + canvass: canvassSlice.reducer, duplicates: potentialDuplicatesSlice.reducer, emails: emailsSlice.reducer, events: eventsSlice.reducer, @@ -108,11 +111,11 @@ const reducer = { const listenerMiddleware = createListenerMiddleware(); listenerMiddleware.startListening({ - actionCreator: canvassAssignmentCreated, + actionCreator: areaAssignmentCreated, effect: (action) => { - const canvassAssignment = action.payload; + const areaAssignment = action.payload; Router.push( - `/organize/${canvassAssignment.organization.id}/projects/${canvassAssignment.campaign.id}/canvassassignments/${canvassAssignment.id}` + `/organize/${areaAssignment.organization.id}/projects/${areaAssignment.campaign.id}/areaassignments/${areaAssignment.id}` ); }, }); diff --git a/src/features/canvassAssignments/components/AreaCard.tsx b/src/features/areaAssignments/components/AreaCard.tsx similarity index 86% rename from src/features/canvassAssignments/components/AreaCard.tsx rename to src/features/areaAssignments/components/AreaCard.tsx index 483fcec6da..a6fb1f3e1b 100644 --- a/src/features/canvassAssignments/components/AreaCard.tsx +++ b/src/features/areaAssignments/components/AreaCard.tsx @@ -20,12 +20,14 @@ import { useNumericRouteParams } from 'core/hooks'; import { AreaCardData, ZetkinAssignmentAreaStatsItem, - ZetkinCanvassAssignment, + ZetkinAreaAssignment, } from '../types'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; type AreaCardProps = { areas: ZetkinAssignmentAreaStatsItem[]; - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; data: AreaCardData[]; maxVisitedHouseholds: number; }; @@ -47,6 +49,7 @@ const AreaCard: FC = ({ data, maxVisitedHouseholds, }) => { + const messages = useMessages(messageIds); const { orgId } = useNumericRouteParams(); const router = useRouter(); @@ -56,7 +59,7 @@ const AreaCard: FC = ({ x: point.hour !== '0' ? `${point.date} ${point.hour}` : point.date, y: point.householdVisits, })), - id: `Households Visited`, + id: messages.overview.progress.headers.households(), }; const successfulVisitsSeries: NivoSeries = { @@ -64,7 +67,7 @@ const AreaCard: FC = ({ x: point.hour !== '0' ? `${point.date} ${point.hour}` : point.date, y: point.successfulVisits, })), - id: `Successful Visits`, + id: messages.overview.progress.headers.successful(), }; return [householdVisitsSeries, successfulVisitsSeries]; @@ -75,7 +78,7 @@ const AreaCard: FC = ({ { pathname: `/organize/${orgId}/projects/${ assignment.campaign.id || '' - }/canvassassignments/${assignment.id}/map`, + }/areaassignments/${assignment.id}/map`, query: { navigateToAreaId: areaId }, }, undefined, @@ -111,9 +114,13 @@ const AreaCard: FC = ({ }} variant="h6" > - {areaData?.area.id !== 'noArea' - ? areaData?.area.title || 'Untitled area' - : 'Unassigned visits'} + {areaData?.area.id !== 'noArea' ? ( + areaData?.area.title + ) : ( + + )} = ({ ) : ( - + + } + > = ({ } data={transformedData} defs={[ - linearGradientDef('Households visited', [ + linearGradientDef('householdsVisited', [ { color: theme.palette.primary.light, offset: 0 }, { color: theme.palette.primary.dark, @@ -203,12 +219,12 @@ const AreaCard: FC = ({ backgroundColor: (() => { if (areaData?.area.id !== 'noArea') { return dataPoint.serieId === - 'Household Visits' + 'householdsVisited' ? theme.palette.primary.light : theme.palette.primary.dark; } else { return dataPoint.serieId === - 'Household Visits' + 'householdsVisited' ? theme.palette.grey[400] : theme.palette.grey[900]; } @@ -244,7 +260,7 @@ const AreaCard: FC = ({ - Households visited + = ({ {area.areaId !== 'noArea' && ( - Places visited + = ({ } variant="h6" > - {area.num_visited_places} + {area.num_visited_locations} )} diff --git a/src/features/canvassAssignments/components/AreaSelect.tsx b/src/features/areaAssignments/components/AreaSelect.tsx similarity index 68% rename from src/features/canvassAssignments/components/AreaSelect.tsx rename to src/features/areaAssignments/components/AreaSelect.tsx index 64b42c5161..92fc93234b 100644 --- a/src/features/canvassAssignments/components/AreaSelect.tsx +++ b/src/features/areaAssignments/components/AreaSelect.tsx @@ -16,64 +16,69 @@ import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; import { ZetkinArea } from 'features/areas/types'; import { ZetkinAssignmentAreaStatsItem, - ZetkinCanvassSession, - ZetkinPlace, + ZetkinAreaAssignmentSession, + ZetkinLocation, } from '../types'; import ZUIAvatar from 'zui/ZUIAvatar'; -import isPointInsidePolygon from '../utils/isPointInsidePolygon'; -import useCanvassSessionMutations from '../hooks/useCanvassSessionMutations'; +import isPointInsidePolygon from '../../canvass/utils/isPointInsidePolygon'; +import useAreaAssignmentSessionMutations from '../hooks/useAreaAssingmentSessionMutations'; import { useNumericRouteParams } from 'core/hooks'; +import { Msg, useMessages } from 'core/i18n'; +import areaAssignmentMessageIds from '../l10n/messageIds'; +import areasMessageIds from 'features/areas/l10n/messageIds'; type Props = { + areaAssId: string; areas: ZetkinArea[]; - canvassId: string; filterAreas: (areas: ZetkinArea[], matchString: string) => ZetkinArea[]; filterText: string; + locations: ZetkinLocation[]; onAddAssignee: (person: ZetkinPerson) => void; onClose: () => void; onFilterTextChange: (newValue: string) => void; onSelectArea: (selectedId: string) => void; - places: ZetkinPlace[]; selectedArea?: ZetkinArea | null; selectedAreaStats?: ZetkinAssignmentAreaStatsItem; - sessions: ZetkinCanvassSession[]; + sessions: ZetkinAreaAssignmentSession[]; }; const AreaSelect: FC = ({ areas, - canvassId, + areaAssId, filterAreas, filterText, onAddAssignee, onClose, onFilterTextChange, onSelectArea, - places, + locations, selectedArea, selectedAreaStats, sessions, }) => { + const areaAssignmentMessages = useMessages(areaAssignmentMessageIds); + const { orgId } = useNumericRouteParams(); - const { deleteSession } = useCanvassSessionMutations(orgId, canvassId); + const { deleteSession } = useAreaAssignmentSessionMutations(orgId, areaAssId); const selectedAreaAssignees = sessions .filter((session) => session.area.id == selectedArea?.id) .map((session) => session.assignee); - const placesInSelectedArea: ZetkinPlace[] = []; + const locationsInSelectedArea: ZetkinLocation[] = []; if (selectedArea) { - places.map((place) => { + locations.map((location) => { const isInsideArea = isPointInsidePolygon( - place.position, + location.position, selectedArea.points.map((point) => ({ lat: point[0], lng: point[1] })) ); if (isInsideArea) { - placesInSelectedArea.push(place); + locationsInSelectedArea.push(location); } }); } - const numberOfHouseholdsInSelectedArea = placesInSelectedArea - .map((place) => place.households.length) + const numberOfHouseholdsInSelectedArea = locationsInSelectedArea + .map((location) => location.households.length) .reduce((prev, curr) => prev + curr, 0); return ( @@ -87,9 +92,11 @@ const AreaSelect: FC = ({ )} - {selectedArea - ? selectedArea?.title || 'Untitled area' - : 'Find area'} + {selectedArea ? ( + selectedArea?.title + ) : ( + + )} onClose()}> @@ -111,7 +118,7 @@ const AreaSelect: FC = ({ endAdornment: , }} onChange={(evt) => onFilterTextChange(evt.target.value)} - placeholder="Filter" + placeholder={areaAssignmentMessages.map.findArea.filterPlaceHolder()} sx={{ paddingRight: 2 }} value={filterText} variant="outlined" @@ -137,7 +144,7 @@ const AreaSelect: FC = ({ onClick={() => onSelectArea(area.id)} sx={{ cursor: 'pointer' }} > - {area.title || 'Untitled area'} + {area.title} {assignees.map((assignee) => ( = ({ > {selectedAreaStats.num_successful_visited_households} - Successful visits + + + = ({ > {selectedAreaStats.num_visited_households} - Visited households + + + )} @@ -192,13 +216,23 @@ const AreaSelect: FC = ({ {numberOfHouseholdsInSelectedArea} - Households + + + - {placesInSelectedArea.length} + {locationsInSelectedArea.length} + + + - Places = ({ sx={{ overflowWrap: 'anywhere' }} > {selectedArea && - (selectedArea?.description?.trim() || 'Empty description')} + (selectedArea?.description?.trim() || ( + + ))} - Assignees + + + {!selectedAreaAssignees.length && ( = ({ } sx={{ overflowWrap: 'anywhere' }} > - No assignees + )} {selectedAreaAssignees.map((assignee) => ( @@ -238,7 +278,9 @@ const AreaSelect: FC = ({ ))} - Add assignee + + + = ({ stats }) => { diff --git a/src/features/canvassAssignments/components/AssignmentStatusChip.tsx b/src/features/areaAssignments/components/AssignmentStatusChip.tsx similarity index 71% rename from src/features/canvassAssignments/components/AssignmentStatusChip.tsx rename to src/features/areaAssignments/components/AssignmentStatusChip.tsx index 8c62b85d30..567dc85e85 100644 --- a/src/features/canvassAssignments/components/AssignmentStatusChip.tsx +++ b/src/features/areaAssignments/components/AssignmentStatusChip.tsx @@ -2,10 +2,10 @@ import { FC } from 'react'; import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import { CanvassAssignmentState } from '../hooks/useCanvassAssignmentStatus'; +import { AreaAssignmentState } from '../hooks/useAreaAssignmentStatus'; interface AssigmentStatusChipProps { - state: CanvassAssignmentState; + state: AreaAssignmentState; } const capitalizeFirstLetter = (str: string): string => { @@ -39,12 +39,12 @@ const useStyles = makeStyles((theme) => ({ const AssignmentStatusChip: FC = ({ state }) => { const classes = useStyles(); - const classMap: Record = { - [CanvassAssignmentState.CLOSED]: classes.closed, - [CanvassAssignmentState.OPEN]: classes.open, - [CanvassAssignmentState.SCHEDULED]: classes.scheduled, - [CanvassAssignmentState.UNKNOWN]: classes.draft, - [CanvassAssignmentState.DRAFT]: classes.draft, + const classMap: Record = { + [AreaAssignmentState.CLOSED]: classes.closed, + [AreaAssignmentState.OPEN]: classes.open, + [AreaAssignmentState.SCHEDULED]: classes.scheduled, + [AreaAssignmentState.UNKNOWN]: classes.draft, + [AreaAssignmentState.DRAFT]: classes.draft, }; const colorClassName = classMap[state]; diff --git a/src/features/canvassAssignments/components/MapControls.tsx b/src/features/areaAssignments/components/MapControls.tsx similarity index 100% rename from src/features/canvassAssignments/components/MapControls.tsx rename to src/features/areaAssignments/components/MapControls.tsx diff --git a/src/features/canvassAssignments/components/LayerSettings.tsx b/src/features/areaAssignments/components/MapStyleSettings.tsx similarity index 58% rename from src/features/canvassAssignments/components/LayerSettings.tsx rename to src/features/areaAssignments/components/MapStyleSettings.tsx index 9105fe30d2..6f5d4b8557 100644 --- a/src/features/canvassAssignments/components/LayerSettings.tsx +++ b/src/features/areaAssignments/components/MapStyleSettings.tsx @@ -3,13 +3,15 @@ import { FC } from 'react'; import { Pentagon, Place, SquareRounded } from '@mui/icons-material'; import { MapStyle } from './OrganizerMap'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; -type LayerSettingsProps = { +type MapStyleSettingsProps = { mapStyle: MapStyle; onMapStyleChange: (newMapStyle: MapStyle) => void; }; -const LayerSettings: FC = ({ +const MapStyleSettings: FC = ({ mapStyle, onMapStyleChange, }) => { @@ -33,13 +35,13 @@ const LayerSettings: FC = ({ - - What the markers represent. + + - What the area color represents. + - What to show in the center of the areas. + @@ -130,4 +152,4 @@ const LayerSettings: FC = ({ ); }; -export default LayerSettings; +export default MapStyleSettings; diff --git a/src/features/canvassAssignments/components/MetricCard.tsx b/src/features/areaAssignments/components/MetricCard.tsx similarity index 97% rename from src/features/canvassAssignments/components/MetricCard.tsx rename to src/features/areaAssignments/components/MetricCard.tsx index 83b5d711bf..f5ca5e965f 100644 --- a/src/features/canvassAssignments/components/MetricCard.tsx +++ b/src/features/areaAssignments/components/MetricCard.tsx @@ -63,7 +63,7 @@ const MetricCard: FC = ({ {metric.kind == 'scale5' && ( - The canvasser will respond by giving a rating from 1 to 5 + The areaAssignee will respond by giving a rating from 1 to 5 )} void; - places: ZetkinPlace[]; - sessions: ZetkinCanvassSession[]; + sessions: ZetkinAreaAssignmentSession[]; }; export type MapStyle = { area: 'households' | 'progress' | 'hide' | 'assignees' | 'outlined'; + location: 'dot' | 'households' | 'progress' | 'hide'; overlay: 'assignees' | 'households' | 'progress' | 'hide'; - place: 'dot' | 'households' | 'progress' | 'hide'; }; const OrganizerMap: FC = ({ areas, areaStats, assignment, - canvassAssId, + areaAssId, onAddAssigneeToArea, - places, + locations, sessions, }) => { + const messages = useMessages(messageIds); const [mapStyle, setMapStyle] = useLocalStorage( - `mapStyle-${canvassAssId}`, + `mapStyle-${areaAssId}`, { area: 'assignees', + location: 'dot', overlay: 'assignees', - place: 'dot', } ); @@ -89,11 +92,11 @@ const OrganizerMap: FC = ({ inputValue.length == 0 ? areas.concat() : areas.filter((area) => { - const areaTitle = area.title || 'Untitled area'; - const areaDesc = area.description || 'Empty description'; + const areaDesc = + area.description || messages.areas.default.description(); return ( - areaTitle.toLowerCase().includes(inputValue) || + area.title.toLowerCase().includes(inputValue) || areaDesc.toLowerCase().includes(inputValue) ); }); @@ -229,10 +232,11 @@ const OrganizerMap: FC = ({ {settingsOpen == 'select' && ( { if (selectedArea) { onAddAssigneeToArea(selectedArea, person); @@ -241,7 +245,6 @@ const OrganizerMap: FC = ({ onClose={clearAndCloseSettings} onFilterTextChange={(newValue) => setFilterText(newValue)} onSelectArea={(newValue) => setSelectedId(newValue)} - places={places} selectedArea={selectedArea} selectedAreaStats={areaStats.stats.find( (stat) => stat.areaId == selectedArea?.id @@ -266,7 +269,7 @@ const OrganizerMap: FC = ({ {settingsOpen == 'layers' && ( - setMapStyle(newMapStyle)} /> @@ -292,11 +295,13 @@ const OrganizerMap: FC = ({ zoomControl={false} > { setSelectedId(newId); @@ -307,8 +312,6 @@ const OrganizerMap: FC = ({ } }} overlayStyle={mapStyle.overlay} - places={places} - placeStyle={mapStyle.place} selectedId={selectedId} sessions={sessions} /> diff --git a/src/features/canvassAssignments/components/OrganizerMapFilters/AssigneeFilterContext.tsx b/src/features/areaAssignments/components/OrganizerMapFilters/AssigneeFilterContext.tsx similarity index 100% rename from src/features/canvassAssignments/components/OrganizerMapFilters/AssigneeFilterContext.tsx rename to src/features/areaAssignments/components/OrganizerMapFilters/AssigneeFilterContext.tsx diff --git a/src/features/canvassAssignments/components/OrganizerMapFilters/OrganizerMapFilterBadge.tsx b/src/features/areaAssignments/components/OrganizerMapFilters/OrganizerMapFilterBadge.tsx similarity index 100% rename from src/features/canvassAssignments/components/OrganizerMapFilters/OrganizerMapFilterBadge.tsx rename to src/features/areaAssignments/components/OrganizerMapFilters/OrganizerMapFilterBadge.tsx diff --git a/src/features/canvassAssignments/components/OrganizerMapFilters/index.tsx b/src/features/areaAssignments/components/OrganizerMapFilters/index.tsx similarity index 92% rename from src/features/canvassAssignments/components/OrganizerMapFilters/index.tsx rename to src/features/areaAssignments/components/OrganizerMapFilters/index.tsx index 0ec3c35693..d8d6756235 100644 --- a/src/features/canvassAssignments/components/OrganizerMapFilters/index.tsx +++ b/src/features/areaAssignments/components/OrganizerMapFilters/index.tsx @@ -9,6 +9,8 @@ import { areaFilterContext } from 'features/areas/components/AreaFilters/AreaFil import AddFilterButton from 'features/areas/components/AreaFilters/AddFilterButton'; import FilterDropDown from 'features/areas/components/FilterDropDown'; import { assigneesFilterContext } from './AssigneeFilterContext'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/areaAssignments/l10n/messageIds'; type Props = { areas: ZetkinArea[]; @@ -16,6 +18,7 @@ type Props = { }; const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { + const messages = useMessages(messageIds); const theme = useTheme(); const [openTagsDropdown, setOpenTagsDropdown] = useState< 'add' | number | null @@ -89,7 +92,7 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { > - Add filters to decide what areas you see on the map. + @@ -100,7 +103,9 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { return { icon: , - label: item.group ? item.group.title : 'Ungrouped tags', + label: item.group + ? item.group.title + : messages.map.filter.ungroupedTags(), onClick: () => { if (selected) { setActiveGroupIds( @@ -123,7 +128,7 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { items={[ { icon: , - label: 'Only assigned areas', + label: messages.map.filter.assignees.assigned(), onClick: () => { if (!assigneesFilter || assigneesFilter == 'unassigned') { onAssigneesFilterChange('assigned'); @@ -134,7 +139,7 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { }, { icon: , - label: 'Only unassigned areas', + label: messages.map.filter.assignees.unassigned(), onClick: () => { if (!assigneesFilter || assigneesFilter == 'assigned') { onAssigneesFilterChange('unassigned'); @@ -144,7 +149,7 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { }, }, ]} - label="Assignees" + label={messages.map.filter.assignees.label()} onToggle={() => setOpenAssigneesDropdown(!openAssigneesDropdown)} open={openAssigneesDropdown} startIcon={ @@ -198,7 +203,11 @@ const OrganizerMapFilters: FC = ({ areas, onFilteredIdsChange }) => { }, }; })} - label={info.group ? info.group.title : 'Ungrouped tags'} + label={ + info.group + ? info.group.title + : messages.map.filter.ungroupedTags() + } onToggle={(open) => setOpenTagsDropdown(open ? groupId : null) } diff --git a/src/features/canvassAssignments/components/OrganizerMapRenderer.tsx b/src/features/areaAssignments/components/OrganizerMapRenderer.tsx similarity index 87% rename from src/features/canvassAssignments/components/OrganizerMapRenderer.tsx rename to src/features/areaAssignments/components/OrganizerMapRenderer.tsx index 14e6d5d1ba..6120bfec18 100644 --- a/src/features/canvassAssignments/components/OrganizerMapRenderer.tsx +++ b/src/features/areaAssignments/components/OrganizerMapRenderer.tsx @@ -14,25 +14,25 @@ import { DivIconMarker } from 'features/events/components/LocationModal/DivIconM import ZUIAvatar from 'zui/ZUIAvatar'; import { ZetkinAssignmentAreaStats, - ZetkinCanvassAssignment, - ZetkinCanvassSession, - ZetkinPlace, + ZetkinAreaAssignment, + ZetkinAreaAssignmentSession, + ZetkinLocation, } from '../types'; import { ZetkinArea } from 'features/areas/types'; import objToLatLng from 'features/areas/utils/objToLatLng'; import { assigneesFilterContext } from './OrganizerMapFilters/AssigneeFilterContext'; -import isPointInsidePolygon from '../utils/isPointInsidePolygon'; +import isPointInsidePolygon from '../../canvass/utils/isPointInsidePolygon'; -const PlaceMarker: FC<{ - canvassAssId: string; +const LocationMarker: FC<{ + areaAssId: string; idOfMetricThatDefinesDone: string; - place: ZetkinPlace; - placeStyle: 'dot' | 'households' | 'progress'; -}> = ({ canvassAssId, idOfMetricThatDefinesDone, place, placeStyle }) => { + location: ZetkinLocation; + locationStyle: 'dot' | 'households' | 'progress'; +}> = ({ areaAssId, idOfMetricThatDefinesDone, location, locationStyle }) => { const theme = useTheme(); - if (placeStyle == 'dot') { + if (locationStyle == 'dot') { return ( - + ); - } else if (placeStyle == 'households') { + } else if (locationStyle == 'households') { return ( - + - {place.households.length} + {location.households.length}
{ + location.households.forEach((household) => { const visitInThisAssignment = household.visits.find( - (visit) => visit.canvassAssId == canvassAssId + (visit) => visit.areaAssId == areaAssId ); if (visitInThisAssignment) { visits++; @@ -100,11 +100,11 @@ const PlaceMarker: FC<{ }); const successfulVisitsColorPercent = - (successfulVisits / place.households.length) * 100; - const visitsColorPercent = (visits / place.households.length) * 100; + (successfulVisits / location.households.length) * 100; + const visitsColorPercent = (visits / location.households.length) * 100; return ( - + void; overlayStyle: 'assignees' | 'households' | 'progress' | 'hide'; - placeStyle: 'dot' | 'households' | 'progress' | 'hide'; - places: ZetkinPlace[]; selectedId: string; - sessions: ZetkinCanvassSession[]; + sessions: ZetkinAreaAssignmentSession[]; }; const OrganizerMapRenderer: FC = ({ @@ -174,14 +174,14 @@ const OrganizerMapRenderer: FC = ({ areaStats, areaStyle, assignment, - canvassAssId, + areaAssId, + locations, selectedId, sessions, navigateToAreaId, onSelectedIdChange, overlayStyle, - placeStyle, - places, + locationStyle, }) => { const theme = useTheme(); const reactFGref = useRef(null); @@ -255,29 +255,29 @@ const OrganizerMapRenderer: FC = ({ } ${visitsColorPercent || 1}%)`; }; - const placesByAreaId: Record = {}; + const locationsByAreaId: Record = {}; areas.forEach((area) => { - placesByAreaId[area.id] = []; + locationsByAreaId[area.id] = []; - places.forEach((place) => { + locations.forEach((location) => { const isInsideArea = isPointInsidePolygon( - place.position, + location.position, area.points.map((point) => ({ lat: point[0], lng: point[1], })) ); if (isInsideArea) { - placesByAreaId[area.id].push(place); + locationsByAreaId[area.id].push(location); } }); }); let highestHousholds = 0; - Object.keys(placesByAreaId).forEach((id) => { + Object.keys(locationsByAreaId).forEach((id) => { let numberOfHouseholdsInArea = 0; - placesByAreaId[id].forEach((place) => { - numberOfHouseholdsInArea += place.households.length; + locationsByAreaId[id].forEach((location) => { + numberOfHouseholdsInArea += location.households.length; }); if (numberOfHouseholdsInArea > highestHousholds) { @@ -377,10 +377,10 @@ const OrganizerMapRenderer: FC = ({ ); let numberOfHouseholds = 0; - placesByAreaId[area.id].forEach( - (place) => (numberOfHouseholds += place.households.length) + locationsByAreaId[area.id].forEach( + (location) => (numberOfHouseholds += location.households.length) ); - const numberOfPlaces = placesByAreaId[area.id].length; + const numberOfLocations = locationsByAreaId[area.id].length; const householdColorPercent = (numberOfHouseholds / highestHousholds) * 100; @@ -409,7 +409,9 @@ const OrganizerMapRenderer: FC = ({ padding="2px 6px" sx={{ translate: '-50% -50%' }} > - {numberOfPlaces} + + {numberOfLocations} + {numberOfHouseholds} @@ -552,13 +554,13 @@ const OrganizerMapRenderer: FC = ({ ); })} - {placeStyle != 'hide' && - places.map((place) => { - //Find ids of area/s that the place is in + {locationStyle != 'hide' && + locations.map((location) => { + //Find ids of area/s that the location is in const areaIds: string[] = []; areas.forEach((area) => { const isInsideArea = isPointInsidePolygon( - place.position, + location.position, area.points.map((point) => ({ lat: point[0], lng: point[1], @@ -586,19 +588,17 @@ const OrganizerMapRenderer: FC = ({ } } - //Check if the place has housholds with visits in this assignment - const hasVisitsInThisAssignment = place.households.some( + //Check if the location has housholds with visits in this assignment + const hasVisitsInThisAssignment = location.households.some( (household) => - !!household.visits.find( - (visit) => visit.canvassAssId == canvassAssId - ) + !!household.visits.find((visit) => visit.areaAssId == areaAssId) ); - //If user wants to see progress of places, - //don't show places outside of assigned areas + //If user wants to see progress of locations, + //don't show locations outside of assigned areas //unless they have visits in this assignment const hideFromProgressView = - placeStyle == 'progress' && + locationStyle == 'progress' && !idOfAreaInThisAssignment && !hasVisitsInThisAssignment; @@ -607,15 +607,15 @@ const OrganizerMapRenderer: FC = ({ } return ( - metric.definesDone)?.id || '' } - place={place} - placeStyle={placeStyle} + location={location} + locationStyle={locationStyle} /> ); })} diff --git a/src/features/areaAssignments/hooks/useAreaAssignment.ts b/src/features/areaAssignments/hooks/useAreaAssignment.ts new file mode 100644 index 0000000000..1bd6b23a5e --- /dev/null +++ b/src/features/areaAssignments/hooks/useAreaAssignment.ts @@ -0,0 +1,24 @@ +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinAreaAssignment } from '../types'; +import { areaAssignmentLoad, areaAssignmentLoaded } from '../store'; + +export default function useAreaAssignment(orgId: number, areaAssId: string) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const areaAssignmenList = useAppSelector( + (state) => state.areaAssignments.areaAssignmentList.items + ); + const areaAssignmentItem = areaAssignmenList.find( + (item) => item.id == areaAssId + ); + + return loadItemIfNecessary(areaAssignmentItem, dispatch, { + actionOnLoad: () => areaAssignmentLoad(areaAssId), + actionOnSuccess: (data) => areaAssignmentLoaded(data), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/areaassignments/${areaAssId}` + ), + }); +} diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts b/src/features/areaAssignments/hooks/useAreaAssignmentActivities.ts similarity index 70% rename from src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts rename to src/features/areaAssignments/hooks/useAreaAssignmentActivities.ts index e8930f0a3c..768b83bd67 100644 --- a/src/features/canvassAssignments/hooks/useCanvassAssignmentActivities.ts +++ b/src/features/areaAssignments/hooks/useAreaAssignmentActivities.ts @@ -4,36 +4,36 @@ import { LoadingFuture, ResolvedFuture, } from 'core/caching/futures'; -import { ZetkinCanvassAssignment } from '../types'; +import { ZetkinAreaAssignment } from '../types'; import { loadListIfNecessary } from 'core/caching/cacheUtils'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { canvassAssignmentsLoad, canvassAssignmentsLoaded } from '../store'; +import { areaAssignmentsLoad, areaAssignmentsLoaded } from '../store'; import { ACTIVITIES, CampaignActivity } from 'features/campaigns/types'; import useFeature from 'utils/featureFlags/useFeature'; import { AREAS } from 'utils/featureFlags'; import { getUTCDateWithoutTime } from '../../../utils/dateUtils'; -export default function useCanvassAssignmentActivities( +export default function useAreaAssignmentActivities( orgId: number, campId?: number ): IFuture { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const list = useAppSelector( - (state) => state.canvassAssignments.canvassAssignmentList + (state) => state.areaAssignments.areaAssignmentList ); - const hasCanvassing = useFeature(AREAS); - if (!hasCanvassing) { + const hasAreaAssignments = useFeature(AREAS); + if (!hasAreaAssignments) { return new ResolvedFuture([]); } const future = loadListIfNecessary(list, dispatch, { - actionOnLoad: () => canvassAssignmentsLoad(), - actionOnSuccess: (data) => canvassAssignmentsLoaded(data), + actionOnLoad: () => areaAssignmentsLoad(), + actionOnSuccess: (data) => areaAssignmentsLoaded(data), loader: () => - apiClient.get( - `/beta/orgs/${orgId}/canvassassignments` + apiClient.get( + `/beta/orgs/${orgId}/areaassignments` ), }); @@ -48,7 +48,7 @@ export default function useCanvassAssignmentActivities( }) .map((assignment) => ({ data: assignment, - kind: ACTIVITIES.CANVASS_ASSIGNMENT, + kind: ACTIVITIES.AREA_ASSIGNMENT, visibleFrom: getUTCDateWithoutTime(assignment.start_date), visibleUntil: getUTCDateWithoutTime(assignment.end_date), })) diff --git a/src/features/areaAssignments/hooks/useAreaAssignmentMutations.ts b/src/features/areaAssignments/hooks/useAreaAssignmentMutations.ts new file mode 100644 index 0000000000..cad97d4929 --- /dev/null +++ b/src/features/areaAssignments/hooks/useAreaAssignmentMutations.ts @@ -0,0 +1,28 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinAreaAssignment, ZetkinAreaAssignmentPatchbody } from '../types'; +import { areaAssignmentDeleted, areaAssignmentUpdated } from '../store'; + +export default function useAreaAssignmentMutations( + orgId: number, + areaAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return { + deleteAreaAssignment: async () => { + await apiClient.delete( + `/beta/orgs/${orgId}/areaassignments/${areaAssId}` + ); + dispatch(areaAssignmentDeleted(parseInt(areaAssId))); + }, + updateAreaAssignment: async (data: ZetkinAreaAssignmentPatchbody) => { + const updated = await apiClient.patch< + ZetkinAreaAssignment, + ZetkinAreaAssignmentPatchbody + >(`/beta/orgs/${orgId}/areaassignments/${areaAssId}`, data); + + dispatch(areaAssignmentUpdated(updated)); + }, + }; +} diff --git a/src/features/areaAssignments/hooks/useAreaAssignmentSessions.ts b/src/features/areaAssignments/hooks/useAreaAssignmentSessions.ts new file mode 100644 index 0000000000..832a89b7d3 --- /dev/null +++ b/src/features/areaAssignments/hooks/useAreaAssignmentSessions.ts @@ -0,0 +1,29 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinAreaAssignmentSession } from '../types'; +import { + areaAssignmentSessionsLoad, + areaAssignmentSessionsLoaded, +} from '../store'; + +export default function useAreaAssignmentSessions( + orgId: number, + areaAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const sessions = useAppSelector( + (state) => state.areaAssignments.sessionsByAssignmentId[areaAssId] + ); + + return loadListIfNecessary(sessions, dispatch, { + actionOnLoad: () => dispatch(areaAssignmentSessionsLoad(areaAssId)), + + actionOnSuccess: (data) => + dispatch(areaAssignmentSessionsLoaded([areaAssId, data])), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/areaassignments/${areaAssId}/sessions` + ), + }); +} diff --git a/src/features/areaAssignments/hooks/useAreaAssignmentStats.ts b/src/features/areaAssignments/hooks/useAreaAssignmentStats.ts new file mode 100644 index 0000000000..cd8d30e56e --- /dev/null +++ b/src/features/areaAssignments/hooks/useAreaAssignmentStats.ts @@ -0,0 +1,24 @@ +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinAreaAssignmentStats } from '../types'; +import { statsLoad, statsLoaded } from '../store'; + +export default function useAreaAssignmentStats( + orgId: number, + areaAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const statsItem = useAppSelector( + (state) => state.areaAssignments.statsByAreaAssId[areaAssId] + ); + + return loadItemIfNecessary(statsItem, dispatch, { + actionOnLoad: () => statsLoad(areaAssId), + actionOnSuccess: (data) => statsLoaded([areaAssId, data]), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/areaassignments/${areaAssId}/stats` + ), + }); +} diff --git a/src/features/areaAssignments/hooks/useAreaAssignmentStatus.tsx b/src/features/areaAssignments/hooks/useAreaAssignmentStatus.tsx new file mode 100644 index 0000000000..fe64342e2d --- /dev/null +++ b/src/features/areaAssignments/hooks/useAreaAssignmentStatus.tsx @@ -0,0 +1,52 @@ +import dayjs from 'dayjs'; + +import useAreaAssignment from './useAreaAssignment'; + +export enum AreaAssignmentState { + CLOSED = 'closed', + DRAFT = 'draft', + OPEN = 'open', + SCHEDULED = 'scheduled', + UNKNOWN = 'unknown', +} + +export default function useAreaAssignmentStatus( + orgId: number, + areaAssId: string +): AreaAssignmentState { + const { data: areaAssignment } = useAreaAssignment(orgId, areaAssId); + + if (!areaAssignment) { + return AreaAssignmentState.UNKNOWN; + } + + const now = dayjs(); + + if (!areaAssignment.start_date) { + return AreaAssignmentState.DRAFT; + } + + const startDate = dayjs(areaAssignment.start_date); + + if (startDate.isAfter(now)) { + return AreaAssignmentState.SCHEDULED; + } + + if (areaAssignment.end_date) { + const endDate = dayjs(areaAssignment.end_date); + + if (endDate.isBefore(now)) { + return AreaAssignmentState.CLOSED; + } + + if (startDate.isBefore(now) || startDate.isSame(now)) { + return AreaAssignmentState.OPEN; + } + } + + if (!areaAssignment.end_date && startDate.isBefore(now)) { + return AreaAssignmentState.OPEN; + } + + return AreaAssignmentState.UNKNOWN; +} diff --git a/src/features/canvassAssignments/hooks/useCanvassSessionMutations.ts b/src/features/areaAssignments/hooks/useAreaAssingmentSessionMutations.ts similarity index 67% rename from src/features/canvassAssignments/hooks/useCanvassSessionMutations.ts rename to src/features/areaAssignments/hooks/useAreaAssingmentSessionMutations.ts index fc78413f2c..c1f4165890 100644 --- a/src/features/canvassAssignments/hooks/useCanvassSessionMutations.ts +++ b/src/features/areaAssignments/hooks/useAreaAssingmentSessionMutations.ts @@ -1,9 +1,9 @@ import { useApiClient, useAppDispatch } from 'core/hooks'; import { sessionDeleted } from '../store'; -export default function useCanvassSessionMutations( +export default function useAreaAssignmentSessionMutations( orgId: number, - canvassAssId: string + areaAssId: string ) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); @@ -11,13 +11,13 @@ export default function useCanvassSessionMutations( return { deleteSession: async (areaId: string, personId: number) => { await apiClient.delete( - `/beta/orgs/${orgId}/areaassignment/${canvassAssId}/areas/${areaId}/assignees/${personId}` + `/beta/orgs/${orgId}/areaassignment/${areaAssId}/areas/${areaId}/assignees/${personId}` ); dispatch( sessionDeleted({ areaId, assigneeId: personId, - assignmentId: canvassAssId, + assignmentId: areaAssId, }) ); }, diff --git a/src/features/canvassAssignments/hooks/useAssignmentAreaGraph.ts b/src/features/areaAssignments/hooks/useAssignmentAreaGraph.ts similarity index 64% rename from src/features/canvassAssignments/hooks/useAssignmentAreaGraph.ts rename to src/features/areaAssignments/hooks/useAssignmentAreaGraph.ts index 20a7a3775f..892e7c6919 100644 --- a/src/features/canvassAssignments/hooks/useAssignmentAreaGraph.ts +++ b/src/features/areaAssignments/hooks/useAssignmentAreaGraph.ts @@ -5,20 +5,20 @@ import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; export default function useAssignmentAreaStats( orgId: number, - canvassAssId: string + areaAssId: string ) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const stats = useAppSelector( - (state) => state.canvassAssignments.areaGraphByAssignmentId[canvassAssId] + (state) => state.areaAssignments.areaGraphByAssignmentId[areaAssId] ); return loadListIfNecessary(stats, dispatch, { - actionOnLoad: () => areaGraphLoad(canvassAssId), - actionOnSuccess: (data) => areaGraphLoaded([canvassAssId, data]), + actionOnLoad: () => areaGraphLoad(areaAssId), + actionOnSuccess: (data) => areaGraphLoaded([areaAssId, data]), loader: () => apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/areasgraph` + `/beta/orgs/${orgId}/areaassignments/${areaAssId}/areasgraph` ), }); } diff --git a/src/features/canvassAssignments/hooks/useAssignmentAreaStats.ts b/src/features/areaAssignments/hooks/useAssignmentAreaStats.ts similarity index 65% rename from src/features/canvassAssignments/hooks/useAssignmentAreaStats.ts rename to src/features/areaAssignments/hooks/useAssignmentAreaStats.ts index 510c35a9ff..18f3ad3eab 100644 --- a/src/features/canvassAssignments/hooks/useAssignmentAreaStats.ts +++ b/src/features/areaAssignments/hooks/useAssignmentAreaStats.ts @@ -5,20 +5,20 @@ import { ZetkinAssignmentAreaStats } from '../types'; export default function useAssignmentAreaStats( orgId: number, - canvassAssId: string + areaAssId: string ) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const stats = useAppSelector( - (state) => state.canvassAssignments.areaStatsByAssignmentId[canvassAssId] + (state) => state.areaAssignments.areaStatsByAssignmentId[areaAssId] ); return loadItemIfNecessary(stats, dispatch, { - actionOnLoad: () => areaStatsLoad(canvassAssId), - actionOnSuccess: (data) => areaStatsLoaded([canvassAssId, data]), + actionOnLoad: () => areaStatsLoad(areaAssId), + actionOnSuccess: (data) => areaStatsLoaded([areaAssId, data]), loader: () => apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/areastats` + `/beta/orgs/${orgId}/areaassignments/${areaAssId}/areastats` ), }); } diff --git a/src/features/canvassAssignments/hooks/useCanvassInstructions.ts b/src/features/areaAssignments/hooks/useCanvassInstructions.ts similarity index 73% rename from src/features/canvassAssignments/hooks/useCanvassInstructions.ts rename to src/features/areaAssignments/hooks/useCanvassInstructions.ts index 9afcfe97da..4567f56ffd 100644 --- a/src/features/canvassAssignments/hooks/useCanvassInstructions.ts +++ b/src/features/areaAssignments/hooks/useCanvassInstructions.ts @@ -2,24 +2,20 @@ import { useState } from 'react'; import { RootState } from 'core/store'; import { useAppSelector } from 'core/hooks'; -import useCanvassAssignmentMutations from './useCanvassAssignmentMutations'; -import useCanvassAssignment from './useCanvassAssignment'; +import useAreaAssignment from './useAreaAssignment'; +import useAreaAssignmentMutations from './useAreaAssignmentMutations'; -export default function useCanvassInstructions( +export default function useAreaAssignmentInstructions( orgId: number, - canvassAssId: string + areaAssId: string ) { - const { updateCanvassAssignment } = useCanvassAssignmentMutations( - orgId, - canvassAssId + const { updateAreaAssignment } = useAreaAssignmentMutations(orgId, areaAssId); + const { data: canvassAssignment } = useAreaAssignment(orgId, areaAssId); + const areaAssignmentsSlice = useAppSelector( + (state: RootState) => state.areaAssignments ); - const { data: canvassAssignment } = useCanvassAssignment(orgId, canvassAssId); - const canvassAssignmentSlice = useAppSelector( - (state: RootState) => state.canvassAssignments - ); - const canvassAssignmentItems = - canvassAssignmentSlice.canvassAssignmentList.items; - const key = `callerInstructions-${canvassAssId}`; + const canvassAssignmentItems = areaAssignmentsSlice.areaAssignmentList.items; + const key = `canvassInstructions-${areaAssId}`; //Used to force re-render const [pointlessState, setPointlessState] = useState(0); @@ -36,9 +32,7 @@ export default function useCanvassInstructions( }; const isSaving = (): boolean => { - const item = canvassAssignmentItems.find( - (item) => item.id === canvassAssId - ); + const item = canvassAssignmentItems.find((item) => item.id === areaAssId); if (!item) { return false; @@ -83,7 +77,7 @@ export default function useCanvassInstructions( }, save: () => { const lsInstructions = localStorage.getItem(key) || ''; - const saveFuture = updateCanvassAssignment({ + const saveFuture = updateAreaAssignment({ instructions: lsInstructions, }); diff --git a/src/features/areaAssignments/hooks/useCreateAreaAssigneeSession.ts b/src/features/areaAssignments/hooks/useCreateAreaAssigneeSession.ts new file mode 100644 index 0000000000..258e438f05 --- /dev/null +++ b/src/features/areaAssignments/hooks/useCreateAreaAssigneeSession.ts @@ -0,0 +1,22 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + ZetkinAreaAssignmentSession, + ZetkinAreaAssignmentSessionPostBody, +} from '../types'; +import { areaAssignmentSessionCreated } from '../store'; + +export default function useCreateAreaAssignmentSession( + orgId: number, + areaAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinAreaAssignmentSessionPostBody) => { + const created = await apiClient.post< + ZetkinAreaAssignmentSession, + ZetkinAreaAssignmentSessionPostBody + >(`/beta/orgs/${orgId}/areaassignments/${areaAssId}/sessions`, data); + dispatch(areaAssignmentSessionCreated(created)); + }; +} diff --git a/src/features/areaAssignments/hooks/useCreateAreaAssignment.ts b/src/features/areaAssignments/hooks/useCreateAreaAssignment.ts new file mode 100644 index 0000000000..fcb230370d --- /dev/null +++ b/src/features/areaAssignments/hooks/useCreateAreaAssignment.ts @@ -0,0 +1,16 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinAreaAssignment, ZetkinAreaAssignmentPostBody } from '../types'; +import { areaAssignmentCreated } from '../store'; + +export default function useCreateAreaAssignment(orgId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinAreaAssignmentPostBody) => { + const created = await apiClient.post< + ZetkinAreaAssignment, + ZetkinAreaAssignmentPostBody + >(`/beta/orgs/${orgId}/areaassignments`, data); + dispatch(areaAssignmentCreated(created)); + }; +} diff --git a/src/features/areaAssignments/hooks/useLocations.ts b/src/features/areaAssignments/hooks/useLocations.ts new file mode 100644 index 0000000000..2effb8c438 --- /dev/null +++ b/src/features/areaAssignments/hooks/useLocations.ts @@ -0,0 +1,19 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinLocation } from '../types'; +import { locationsLoad, locationsLoaded } from '../store'; + +export default function useLocations(orgId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const locationList = useAppSelector( + (state) => state.areaAssignments.locationList + ); + + return loadListIfNecessary(locationList, dispatch, { + actionOnLoad: () => locationsLoad(), + actionOnSuccess: (data) => locationsLoaded(data), + loader: () => + apiClient.get(`/beta/orgs/${orgId}/locations`), + }); +} diff --git a/src/features/canvassAssignments/hooks/useStartEndAssignment.tsx b/src/features/areaAssignments/hooks/useStartEndAssignment.tsx similarity index 65% rename from src/features/canvassAssignments/hooks/useStartEndAssignment.tsx rename to src/features/areaAssignments/hooks/useStartEndAssignment.tsx index b5f3005f58..4110adc17a 100644 --- a/src/features/canvassAssignments/hooks/useStartEndAssignment.tsx +++ b/src/features/areaAssignments/hooks/useStartEndAssignment.tsx @@ -1,61 +1,58 @@ import dayjs from 'dayjs'; -import useCanvassAssignment from './useCanvassAssignment'; -import useCanvassAssignmentMutations from './useCanvassAssignmentMutations'; +import useAreaAssignment from './useAreaAssignment'; +import useAreaAssignmentMutations from './useAreaAssignmentMutations'; export default function useStartEndAssignment( orgId: number, - canvassAssId: string + areaAssId: string ) { - const canvassAssignment = useCanvassAssignment(orgId, canvassAssId); - const { updateCanvassAssignment } = useCanvassAssignmentMutations( - orgId, - canvassAssId - ); + const areaAssignment = useAreaAssignment(orgId, areaAssId); + const { updateAreaAssignment } = useAreaAssignmentMutations(orgId, areaAssId); const endAssignment = () => { - if (!canvassAssignment.data) { + if (!areaAssignment.data) { return; } const now = dayjs(); const today = now.format('YYYY-MM-DD'); - updateCanvassAssignment({ + updateAreaAssignment({ end_date: today, }); }; const startAssignment = () => { - if (!canvassAssignment.data) { + if (!areaAssignment.data) { return; } const now = dayjs(); const today = now.format('YYYY-MM-DD'); - const { start_date: startStr, end_date: endStr } = canvassAssignment.data; + const { start_date: startStr, end_date: endStr } = areaAssignment.data; if (!startStr && !endStr) { - updateCanvassAssignment({ + updateAreaAssignment({ start_date: today, }); } else if (!startStr) { const endDate = dayjs(endStr); if (endDate.isBefore(today)) { - updateCanvassAssignment({ + updateAreaAssignment({ end_date: null, start_date: today, }); } else if (endDate.isAfter(today)) { - updateCanvassAssignment({ + updateAreaAssignment({ start_date: today, }); } } else if (!endStr) { const startDate = dayjs(startStr); if (startDate.isAfter(today)) { - updateCanvassAssignment({ + updateAreaAssignment({ start_date: today, }); } @@ -67,11 +64,11 @@ export default function useStartEndAssignment( (startDate.isBefore(today) || startDate.isSame(today)) && (endDate.isBefore(today) || endDate.isSame(today)) ) { - updateCanvassAssignment({ + updateAreaAssignment({ end_date: null, }); } else if (startDate.isAfter(today) && endDate.isAfter(today)) { - updateCanvassAssignment({ + updateAreaAssignment({ start_date: today, }); } diff --git a/src/features/areaAssignments/l10n/messageIds.ts b/src/features/areaAssignments/l10n/messageIds.ts new file mode 100644 index 0000000000..f31b5988b3 --- /dev/null +++ b/src/features/areaAssignments/l10n/messageIds.ts @@ -0,0 +1,139 @@ +import { m, makeMessages } from 'core/i18n'; + +export default makeMessages('feat.areaAssignments', { + assignees: { + columns: { + areas: m('Areas'), + name: m('Name'), + }, + }, + default: { + title: m('Untitled area assignment'), + }, + instructions: { + editor: { + confirm: m( + 'Do you want to delete all unsaved changes and go back to saved instructions?' + ), + editorPlaceholder: m('Add instructions for your callers'), + revertLink: m('Revert to saved version?'), + saveButton: m('Save'), + savedMessage: m('Everything is up to date!'), + savingButton: m('Saving...'), + title: m('Caller instructions'), + unsavedMessage: m('You have unsaved changes.'), + }, + title: m('Assignee instructions'), + }, + layout: { + actions: { + delete: m('Delete'), + deleteWarningText: m<{ title: string }>( + 'Are you sure you want to delete {title}?' + ), + end: m('End assignment'), + start: m('Start assignment'), + }, + basicAssignmentStats: { + areas: m<{ numAreas: number }>( + '{numAreas, plural, =0 {No areas} one {1 area} other {# areas}}' + ), + assignees: m<{ numAssignees: number }>( + '{numAssignees, plural, =0 {No assignees} one {1 assignee} other {# assignees}}' + ), + }, + tabs: { + assignees: m('Assignees'), + instructions: m('Instructions'), + map: m('Map'), + overview: m('Overview'), + report: m('Report'), + }, + }, + map: { + areaInfo: { + assignees: { + add: m('Add assignee'), + none: m('No assignees'), + title: m('Assignees'), + }, + stats: { + households: m<{ numHouseholds: number }>( + '{numHouseholds, plural, one {Household} other {Households}}' + ), + locations: m<{ numLocations: number }>( + '{numLocations, plural, one {Location} other {Locations}}' + ), + successful: m<{ numSuccessfulVisits: number }>( + '{numSuccessfulVisits, plural, one {Successful visit} other {Successful visits}}' + ), + visited: m<{ numVisited: number }>( + '{numVisited, plural, =0 {Visited} one {Visited} other {Visited}}' + ), + }, + }, + filter: { + assignees: { + assigned: m('Only assigned areas'), + label: m('Assignees'), + unassigned: m('Only unassigned areas'), + }, + title: m('Add filters to decide what areas you see on the map'), + ungroupedTags: m('Ungrouped tags'), + }, + findArea: { + filterPlaceHolder: m('Filter'), + title: m('Find area'), + }, + mapStyle: { + area: { + label: m('What the area color represents.'), + options: { + assignees: m('Number of assignees'), + hidden: m('Hidden'), + households: m('Number of households'), + outlined: m('Outlined'), + progress: m('Progress in this assignment'), + }, + }, + center: { + label: m('What to show in the center of the area'), + options: { + assignees: m('Assignees'), + hidden: m('Hidden'), + households: m('Number of locations and households in the area'), + progress: m('Progress in the area in this assignment'), + }, + }, + markers: { + label: m('What the markers represent'), + options: { + dot: m('Dot'), + hidden: m('Hidden'), + households: m('Number of households at the location'), + progress: m('Progress in this assignment'), + }, + }, + }, + }, + overview: { + empty: { + description: m('This assignment has not been planned yet'), + startPlanningButton: m('Plan now'), + }, + progress: { + headers: { + households: m('Households visited'), + locations: m('Locations visited'), + successful: m('Successful visits'), + }, + statsTitle: m('Progress'), + unassignedVisits: { + description: m( + 'This graph shows visits made outside the assigned areas' + ), + title: m('Unassigned visits'), + }, + }, + }, +}); diff --git a/src/features/areaAssignments/layouts/AreaAssignmentLayout.tsx b/src/features/areaAssignments/layouts/AreaAssignmentLayout.tsx new file mode 100644 index 0000000000..3ab99189d3 --- /dev/null +++ b/src/features/areaAssignments/layouts/AreaAssignmentLayout.tsx @@ -0,0 +1,168 @@ +import { Box } from '@mui/system'; +import router, { useRouter } from 'next/router'; +import { Button, Typography } from '@mui/material'; +import { FC, ReactNode, useContext } from 'react'; +import { Delete, Pentagon, People } from '@mui/icons-material'; + +import AssignmentStatusChip from '../components/AssignmentStatusChip'; +import getAreaAssignees from '../utils/getAreaAssignees'; +import TabbedLayout from 'utils/layout/TabbedLayout'; +import useAreaAssignment from '../hooks/useAreaAssignment'; +import useAreaAssignmentMutations from '../hooks/useAreaAssignmentMutations'; +import useAreaAssignmentSessions from '../hooks/useAreaAssignmentSessions'; +import useAreaAssignmentStats from '../hooks/useAreaAssignmentStats'; +import useStartEndAssignment from '../hooks/useStartEndAssignment'; +import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; +import ZUIFuture from 'zui/ZUIFuture'; +import ZUIDateRangePicker from 'zui/ZUIDateRangePicker/ZUIDateRangePicker'; +import useAreaAssignmentStatus, { + AreaAssignmentState, +} from '../hooks/useAreaAssignmentStatus'; +import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; +import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type AreaAssignmentLayoutProps = { + areaAssId: string; + campId: number; + children: ReactNode; + orgId: number; +}; + +const AreaAssignmentLayout: FC = ({ + children, + orgId, + campId, + areaAssId, +}) => { + const messages = useMessages(messageIds); + const path = useRouter().pathname; + const areaAssignment = useAreaAssignment(orgId, areaAssId).data; + const { deleteAreaAssignment, updateAreaAssignment } = + useAreaAssignmentMutations(orgId, areaAssId); + + const allSessions = useAreaAssignmentSessions(orgId, areaAssId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === areaAssId + ); + + const stats = useAreaAssignmentStats(orgId, areaAssId); + const state = useAreaAssignmentStatus(orgId, areaAssId); + const { startAssignment, endAssignment } = useStartEndAssignment( + orgId, + areaAssId + ); + const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); + + const areaAssignees = getAreaAssignees(sessions); + + const isMapTab = path.endsWith('/map'); + + if (!areaAssignment) { + return null; + } + + const handleDelete = () => { + deleteAreaAssignment(); + router.push( + `/organize/${orgId}/projects/${areaAssignment.campaign.id || ''} ` + ); + }; + + return ( + + {state == AreaAssignmentState.OPEN ? ( + + ) : ( + + )} + , + onSelect: () => { + showConfirmDialog({ + onSubmit: handleDelete, + title: messages.layout.actions.delete(), + warningText: messages.layout.actions.deleteWarningText({ + title: areaAssignment.title, + }), + }); + }, + startIcon: , + }, + ]} + /> + + } + baseHref={`/organize/${orgId}/projects/${campId}/areaassignments/${areaAssId}`} + belowActionButtons={ + { + updateAreaAssignment({ + end_date: endDate, + start_date: startDate, + }); + }} + startDate={areaAssignment.start_date || null} + /> + } + defaultTab="/" + fixedHeight={isMapTab} + subtitle={ + + + + + + {(data) => ( + + + + + + + )} + + + + + + + + + } + tabs={[ + { href: '/', label: messages.layout.tabs.overview() }, + { href: '/map', label: messages.layout.tabs.map() }, + { href: '/assignees', label: messages.layout.tabs.assignees() }, + { href: '/report', label: messages.layout.tabs.report() }, + { href: '/instructions', label: messages.layout.tabs.instructions() }, + ]} + title={ + updateAreaAssignment({ title: newTitle })} + value={areaAssignment.title} + /> + } + > + {children} + + ); +}; + +export default AreaAssignmentLayout; diff --git a/src/features/canvassAssignments/models.ts b/src/features/areaAssignments/models.ts similarity index 58% rename from src/features/canvassAssignments/models.ts rename to src/features/areaAssignments/models.ts index 7c3f794d13..5d3588deb1 100644 --- a/src/features/canvassAssignments/models.ts +++ b/src/features/areaAssignments/models.ts @@ -1,27 +1,27 @@ import mongoose from 'mongoose'; -import { ZetkinMetric, ZetkinPlace } from './types'; +import { ZetkinMetric, ZetkinLocation } from './types'; -type ZetkinCanvassAssignmentModelType = { +type ZetkinAreaAssignmentModelType = { campId: number; end_date: string | null; id: number; instructions: string; metrics: (Omit & { _id: string })[]; orgId: number; - reporting_level: 'household' | 'place' | null; + reporting_level: 'household' | 'location' | null; sessions: { areaId: string; personId: number; }[]; start_date: string | null; - title: string | null; + title: string; }; -type ZetkinPlaceModelType = Omit; +type ZetkinLocationModelType = Omit; -const canvassAssignmentSchema = - new mongoose.Schema({ +const areaAssignmentSchema = new mongoose.Schema( + { campId: Number, end_date: { default: null, @@ -50,9 +50,10 @@ const canvassAssignmentSchema = type: String, }, title: String, - }); + } +); -const placeSchema = new mongoose.Schema({ +const locationSchema = new mongoose.Schema({ description: String, households: [ { @@ -63,7 +64,7 @@ const placeSchema = new mongoose.Schema({ visits: [ { _id: false, - canvassAssId: String, + areaAssId: String, id: String, noteToOfficial: String, personId: Number, @@ -83,10 +84,10 @@ const placeSchema = new mongoose.Schema({ title: String, }); -export type PlaceVisitModelType = { - canvassAssId: string; +export type LocationVisitModelType = { + areaAssId: string; + locationId: string; personId: number; - placeId: string; responses: { metricId: string; responseCounts: number[]; @@ -94,10 +95,10 @@ export type PlaceVisitModelType = { timestamp: string; }; -const placeVisitSchema = new mongoose.Schema({ - canvassAssId: String, +const locationVisitSchema = new mongoose.Schema({ + areaAssId: String, + locationId: String, personId: Number, - placeId: String, responses: [ { metricId: String, @@ -107,17 +108,17 @@ const placeVisitSchema = new mongoose.Schema({ timestamp: String, }); -export const CanvassAssignmentModel: mongoose.Model = - mongoose.models.CanvassAssignment || - mongoose.model( - 'CanvassAssignment', - canvassAssignmentSchema +export const AreaAssignmentModel: mongoose.Model = + mongoose.models.AreaAssignment || + mongoose.model( + 'AreaAssignment', + areaAssignmentSchema ); -export const PlaceModel: mongoose.Model = - mongoose.models.Place || - mongoose.model('Place', placeSchema); +export const LocationModel: mongoose.Model = + mongoose.models.Location || + mongoose.model('Location', locationSchema); -export const PlaceVisitModel: mongoose.Model = - mongoose.models.PlaceVisit || - mongoose.model('PlaceVisit', placeVisitSchema); +export const LocationVisitModel: mongoose.Model = + mongoose.models.LocationVisit || + mongoose.model('LocationVisit', locationVisitSchema); diff --git a/src/features/areaAssignments/store.ts b/src/features/areaAssignments/store.ts new file mode 100644 index 0000000000..e72aea5c81 --- /dev/null +++ b/src/features/areaAssignments/store.ts @@ -0,0 +1,322 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + findOrAddItem, + RemoteItem, + remoteItem, + remoteList, + RemoteList, +} from 'utils/storeUtils'; +import { + AreaCardData, + ZetkinAreaAssignmentStats, + ZetkinAreaAssignment, + ZetkinAreaAssignmentSession, + ZetkinLocation, + ZetkinAssignmentAreaStats, + SessionDeletedPayload, +} from './types'; + +export interface AreaAssignmentsStoreSlice { + areaGraphByAssignmentId: Record< + string, + RemoteList + >; + areaStatsByAssignmentId: Record< + string, + RemoteItem + >; + areaAssignmentList: RemoteList; + sessionsByAssignmentId: Record< + string, + RemoteList + >; + locationList: RemoteList; + statsByAreaAssId: Record< + string, + RemoteItem + >; +} + +const initialState: AreaAssignmentsStoreSlice = { + areaAssignmentList: remoteList(), + areaGraphByAssignmentId: {}, + areaStatsByAssignmentId: {}, + locationList: remoteList(), + sessionsByAssignmentId: {}, + statsByAreaAssId: {}, +}; + +const areaAssignmentSlice = createSlice({ + initialState: initialState, + name: 'areaAssignments', + reducers: { + areaAssignmentCreated: ( + state, + action: PayloadAction + ) => { + const areaAssignment = action.payload; + const item = remoteItem(areaAssignment.id, { + data: areaAssignment, + loaded: new Date().toISOString(), + }); + + state.areaAssignmentList.items.push(item); + }, + areaAssignmentDeleted: (state, action: PayloadAction) => { + const areaAssId = action.payload; + const areaAssignmentItem = state.areaAssignmentList.items.find( + (item) => item.id === areaAssId + ); + + if (areaAssignmentItem) { + areaAssignmentItem.deleted = true; + } + }, + areaAssignmentLoad: (state, action: PayloadAction) => { + const areaAssId = action.payload; + const item = state.areaAssignmentList.items.find( + (item) => item.id == areaAssId + ); + + if (item) { + item.isLoading = true; + } else { + state.areaAssignmentList.items = state.areaAssignmentList.items.concat([ + remoteItem(areaAssId, { isLoading: true }), + ]); + } + }, + areaAssignmentLoaded: ( + state, + action: PayloadAction + ) => { + const areaAssignment = action.payload; + const item = state.areaAssignmentList.items.find( + (item) => item.id == areaAssignment.id + ); + + if (!item) { + throw new Error('Finished loading item that never started loading'); + } + + item.data = areaAssignment; + item.isLoading = false; + item.loaded = new Date().toISOString(); + }, + areaAssignmentSessionCreated: ( + 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); + + const hasStatsItem = !!state.areaStatsByAssignmentId[ + session.assignment.id + ].data?.stats.find((statsItem) => statsItem.areaId == session.area.id); + + if (!hasStatsItem) { + state.areaStatsByAssignmentId[session.assignment.id].isStale = true; + } + }, + areaAssignmentSessionsLoad: (state, action: PayloadAction) => { + const assignmentId = action.payload; + + if (!state.sessionsByAssignmentId[assignmentId]) { + state.sessionsByAssignmentId[assignmentId] = remoteList(); + } + + state.sessionsByAssignmentId[assignmentId].isLoading = true; + }, + areaAssignmentSessionsLoaded: ( + state, + action: PayloadAction<[string, ZetkinAreaAssignmentSession[]]> + ) => { + const [assignmentId, sessions] = action.payload; + + state.sessionsByAssignmentId[assignmentId] = remoteList( + sessions.map((session) => ({ ...session, id: session.assignee.id })) + ); + + state.sessionsByAssignmentId[assignmentId].loaded = + new Date().toISOString(); + }, + areaAssignmentUpdated: ( + state, + action: PayloadAction + ) => { + const assignment = action.payload; + const item = findOrAddItem(state.areaAssignmentList, assignment.id); + + item.data = assignment; + item.loaded = new Date().toISOString(); + }, + areaAssignmentsLoad: (state) => { + state.areaAssignmentList.isLoading = true; + }, + areaAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + state.areaAssignmentList = remoteList(action.payload); + state.areaAssignmentList.loaded = new Date().toISOString(); + }, + areaGraphLoad: (state, action: PayloadAction) => { + const assignmentId = action.payload; + + if (!state.areaGraphByAssignmentId[assignmentId]) { + state.areaGraphByAssignmentId[assignmentId] = remoteList(); + } + + state.areaGraphByAssignmentId[assignmentId].isLoading = true; + }, + areaGraphLoaded: ( + state, + action: PayloadAction<[string, AreaCardData[]]> + ) => { + const [assignmentId, graphData] = action.payload; + + state.areaGraphByAssignmentId[assignmentId] = remoteList( + graphData.map((data) => ({ ...data, id: data.area.id })) + ); + + state.areaGraphByAssignmentId[assignmentId].loaded = + new Date().toISOString(); + }, + areaStatsLoad: (state, action: PayloadAction) => { + const areaAssId = action.payload; + + if (!state.areaStatsByAssignmentId[areaAssId]) { + state.areaStatsByAssignmentId[areaAssId] = remoteItem(areaAssId); + } + const statsItem = state.areaStatsByAssignmentId[areaAssId]; + + state.areaStatsByAssignmentId[areaAssId] = remoteItem(areaAssId, { + data: statsItem?.data || null, + isLoading: true, + }); + }, + areaStatsLoaded: ( + state, + action: PayloadAction<[string, ZetkinAssignmentAreaStats]> + ) => { + const [areaAssId, stats] = action.payload; + + state.areaStatsByAssignmentId[areaAssId] = remoteItem(areaAssId, { + data: { id: areaAssId, ...stats }, + isLoading: false, + isStale: false, + loaded: new Date().toISOString(), + }); + }, + locationCreated: (state, action: PayloadAction) => { + const location = action.payload; + const item = remoteItem(location.id, { + data: location, + loaded: new Date().toISOString(), + }); + + state.locationList.items.push(item); + }, + locationUpdated: (state, action: PayloadAction) => { + const location = action.payload; + const item = findOrAddItem(state.locationList, location.id); + + item.data = location; + item.loaded = new Date().toISOString(); + }, + locationsInvalidated: (state) => { + state.locationList.isStale = true; + }, + locationsLoad: (state) => { + state.locationList.isLoading = true; + }, + locationsLoaded: (state, action: PayloadAction) => { + const timestamp = new Date().toISOString(); + const locations = action.payload; + state.locationList = remoteList(locations); + state.locationList.loaded = timestamp; + state.locationList.items.forEach((item) => (item.loaded = timestamp)); + }, + + sessionDeleted: (state, action: PayloadAction) => { + const { areaId, assignmentId, assigneeId } = action.payload; + + const sessionsList = state.sessionsByAssignmentId[assignmentId]; + + if (sessionsList) { + const filteredSessions = sessionsList.items.filter( + (item) => + !( + item.data?.area.id === areaId && + item.data?.assignee.id === assigneeId + ) + ); + state.sessionsByAssignmentId[assignmentId] = { + ...sessionsList, + items: filteredSessions, + }; + } + }, + statsLoad: (state, action: PayloadAction) => { + const areaAssId = action.payload; + + if (!state.statsByAreaAssId[areaAssId]) { + state.statsByAreaAssId[areaAssId] = remoteItem(areaAssId); + } + const statsItem = state.statsByAreaAssId[areaAssId]; + + state.statsByAreaAssId[areaAssId] = remoteItem(areaAssId, { + data: statsItem?.data || null, + isLoading: true, + }); + }, + statsLoaded: ( + state, + action: PayloadAction<[string, ZetkinAreaAssignmentStats]> + ) => { + const [areaAssId, stats] = action.payload; + + state.statsByAreaAssId[areaAssId] = remoteItem(areaAssId, { + data: { id: areaAssId, ...stats }, + isLoading: false, + isStale: false, + loaded: new Date().toISOString(), + }); + }, + }, +}); + +export default areaAssignmentSlice; +export const { + areaGraphLoad, + areaGraphLoaded, + areaStatsLoad, + areaStatsLoaded, + areaAssignmentCreated, + areaAssignmentDeleted, + areaAssignmentLoad, + areaAssignmentLoaded, + areaAssignmentUpdated, + areaAssignmentsLoad, + areaAssignmentsLoaded, + areaAssignmentSessionCreated, + areaAssignmentSessionsLoad, + areaAssignmentSessionsLoaded, + locationCreated, + locationsInvalidated, + locationsLoad, + locationsLoaded, + locationUpdated, + sessionDeleted, + statsLoad, + statsLoaded, +} = areaAssignmentSlice.actions; diff --git a/src/features/canvassAssignments/types.ts b/src/features/areaAssignments/types.ts similarity index 54% rename from src/features/canvassAssignments/types.ts rename to src/features/areaAssignments/types.ts index 2b2846ee8a..7ec51b1069 100644 --- a/src/features/canvassAssignments/types.ts +++ b/src/features/areaAssignments/types.ts @@ -1,10 +1,10 @@ import { ZetkinArea } from 'features/areas/types'; import { ZetkinPerson } from 'utils/types/zetkin'; -export type CanvasserInfo = { +export type AreaAssigneeInfo = { id: number; person: ZetkinPerson; - sessions: ZetkinCanvassSession[]; + sessions: ZetkinAreaAssignmentSession[]; }; export type ZetkinMetric = { @@ -15,7 +15,7 @@ export type ZetkinMetric = { question: string; }; -export type ZetkinCanvassAssignment = { +export type ZetkinAreaAssignment = { campaign: { id: number; }; @@ -26,27 +26,23 @@ export type ZetkinCanvassAssignment = { organization: { id: number; }; - reporting_level: 'household' | 'place'; + reporting_level: 'household' | 'location'; start_date: string | null; - title: string | null; -}; - -export type AssignmentWithAreas = ZetkinCanvassAssignment & { - areas: ZetkinArea[]; + title: string; }; -export type ZetkinCanvassAssignmentPostBody = Partial< - Omit +export type ZetkinAreaAssignmentPostBody = Partial< + Omit > & { campaign_id: number; metrics: Omit[]; }; -export type ZetkinCanvassAssignmentPatchbody = Partial< - Omit +export type ZetkinAreaAssignmentPatchbody = Partial< + Omit >; export type Visit = { - canvassAssId: string | null; + areaAssId: string | null; id: string; noteToOfficial: string | null; personId: number; @@ -64,79 +60,49 @@ export type Household = { visits: Visit[]; }; -export type HouseholdPatchBody = Partial>; - -export type ZetkinPlace = { - description: string | null; +export type ZetkinLocation = { + description: string; households: Household[]; id: string; orgId: number; position: { lat: number; lng: number }; - title: string | null; -}; - -export type ZetkinPlaceVisit = { - canvassAssId: string; - id: string; - personId: number; - placeId: string; - responses: { - metricId: string; - responseCounts: number[]; - }[]; - timestamp: string; -}; - -export type ZetkinPlaceVisitPostBody = Omit< - ZetkinPlaceVisit, - 'id' | 'timestamp' | 'personId' ->; - -export type ZetkinPlacePostBody = Partial< - Omit ->; - -export type ZetkinPlacePatchBody = Partial< - Omit -> & { - households?: Partial> & - { visits?: Partial>[] }[]; + title: string; }; -export type ZetkinCanvassSession = { +export type ZetkinAreaAssignmentSession = { area: ZetkinArea; assignee: ZetkinPerson; - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; }; -export type ZetkinCanvassSessionPostBody = { +export type ZetkinAreaAssignmentSessionPostBody = { areaId: string; personId: number; }; -export type ZetkinCanvassAssignmentStats = { +export type ZetkinAreaAssignmentStats = { metrics: { metric: ZetkinMetric; values: number[]; }[]; num_areas: number; num_households: number; - num_places: number; + num_locations: 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; + num_visited_locations: number; + num_visited_locations_outside_areas: number; }; export type ZetkinAssignmentAreaStatsItem = { areaId: string; num_households: number; - num_places: number; + num_locations: number; num_successful_visited_households: number; num_visited_households: number; - num_visited_places: number; + num_visited_locations: number; }; export type ZetkinAssignmentAreaStats = { diff --git a/src/features/canvassAssignments/utils/getCanvassers.tsx b/src/features/areaAssignments/utils/getAreaAssignees.ts similarity index 55% rename from src/features/canvassAssignments/utils/getCanvassers.tsx rename to src/features/areaAssignments/utils/getAreaAssignees.ts index d82b8e3c33..5fe9e105c1 100644 --- a/src/features/canvassAssignments/utils/getCanvassers.tsx +++ b/src/features/areaAssignments/utils/getAreaAssignees.ts @@ -1,7 +1,7 @@ -import { CanvasserInfo, ZetkinCanvassSession } from '../types'; +import { AreaAssigneeInfo, ZetkinAreaAssignmentSession } from '../types'; -const getCanvassers = (sessions: ZetkinCanvassSession[]) => { - const sessionsByPersonId: Record = {}; +const getAreaAssignees = (sessions: ZetkinAreaAssignmentSession[]) => { + const sessionsByPersonId: Record = {}; sessions.forEach((session) => { if (session.assignee && session.assignee.id) { @@ -17,8 +17,8 @@ const getCanvassers = (sessions: ZetkinCanvassSession[]) => { } }); - const canvassers = Object.values(sessionsByPersonId); - return canvassers; + const areaAssignees = Object.values(sessionsByPersonId); + return areaAssignees; }; -export default getCanvassers; +export default getAreaAssignees; diff --git a/src/features/canvassAssignments/utils/getAreaData.spec.ts b/src/features/areaAssignments/utils/getAreaData.spec.ts similarity index 95% rename from src/features/canvassAssignments/utils/getAreaData.spec.ts rename to src/features/areaAssignments/utils/getAreaData.spec.ts index 4c042f5dac..772e75491c 100644 --- a/src/features/canvassAssignments/utils/getAreaData.spec.ts +++ b/src/features/areaAssignments/utils/getAreaData.spec.ts @@ -25,7 +25,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -53,7 +53,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -100,7 +100,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -141,7 +141,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -183,7 +183,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -197,7 +197,7 @@ describe('getAreasData()', () => { title: 'household 2', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -245,7 +245,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -253,7 +253,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-13T13:00:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -261,7 +261,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-11T11:00:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '3', noteToOfficial: null, personId: 1, @@ -275,7 +275,7 @@ describe('getAreasData()', () => { title: 'household 2', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '4', noteToOfficial: null, personId: 1, @@ -329,7 +329,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -370,7 +370,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -423,7 +423,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -431,7 +431,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-13T12:00:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -496,7 +496,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -504,7 +504,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-14T09:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -512,7 +512,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-14T09:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -565,7 +565,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -573,7 +573,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-14T00:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -620,7 +620,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -628,7 +628,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T23:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -681,7 +681,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -689,7 +689,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T23:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -703,7 +703,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '3', noteToOfficial: null, personId: 1, @@ -711,7 +711,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T01:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '4', noteToOfficial: null, personId: 1, @@ -764,7 +764,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -772,7 +772,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T23:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -786,7 +786,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -800,7 +800,7 @@ describe('getAreasData()', () => { title: 'household 2', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '3', noteToOfficial: null, personId: 1, @@ -808,7 +808,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T01:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '4', noteToOfficial: null, personId: 1, @@ -861,7 +861,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -869,7 +869,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T23:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '2', noteToOfficial: null, personId: 1, @@ -883,7 +883,7 @@ describe('getAreasData()', () => { title: 'household 1', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '1', noteToOfficial: null, personId: 1, @@ -897,7 +897,7 @@ describe('getAreasData()', () => { title: 'household 2', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '3', noteToOfficial: null, personId: 1, @@ -905,7 +905,7 @@ describe('getAreasData()', () => { timestamp: '2024-10-31T01:10:00.000Z', }, { - canvassAssId: '1', + areaAssId: '1', id: '4', noteToOfficial: null, personId: 1, @@ -919,7 +919,7 @@ describe('getAreasData()', () => { title: 'household 3', visits: [ { - canvassAssId: '1', + areaAssId: '1', id: '3', noteToOfficial: null, personId: 1, diff --git a/src/features/canvassAssignments/utils/getAreaData.ts b/src/features/areaAssignments/utils/getAreaData.ts similarity index 100% rename from src/features/canvassAssignments/utils/getAreaData.ts rename to src/features/areaAssignments/utils/getAreaData.ts diff --git a/src/features/areas/components/AreaFilters/AddFilterButton.tsx b/src/features/areas/components/AreaFilters/AddFilterButton.tsx index 17ada2b3e2..5b9f2d0243 100644 --- a/src/features/areas/components/AreaFilters/AddFilterButton.tsx +++ b/src/features/areas/components/AreaFilters/AddFilterButton.tsx @@ -6,6 +6,8 @@ import { MenuItem } from '@mui/material'; import { FC, useState } from 'react'; import theme from 'theme'; +import { Msg } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; type Props = { items: { @@ -29,7 +31,7 @@ const AddFilterButton: FC = ({ items, open, onToggle }) => { startIcon={} variant="text" > - Add filter + void; @@ -27,7 +29,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 c88a63d069..73c35b1085 100644 --- a/src/features/areas/components/AreaFilters/index.tsx +++ b/src/features/areas/components/AreaFilters/index.tsx @@ -7,6 +7,8 @@ import { ZetkinTag, ZetkinTagGroup } from 'utils/types/zetkin'; import FilterDropDown from '../FilterDropDown'; import { areaFilterContext } from './AreaFilterContext'; import AddFilterButton from './AddFilterButton'; +import { useMessages } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; type Props = { areas: ZetkinArea[]; @@ -14,6 +16,7 @@ type Props = { }; const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { + const messages = useMessages(messageIds); const theme = useTheme(); const [openDropdown, setOpenDropdown] = useState<'add' | number | null>(null); const { @@ -93,7 +96,11 @@ const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { }, }; })} - label={info.group ? info.group.title : 'Ungrouped tags'} + label={ + info.group + ? info.group.title + : messages.areas.filter.ungroupedTagsLabel() + } onToggle={(open) => setOpenDropdown(open ? groupId : null)} open={openDropdown == groupId} startIcon={ @@ -134,7 +141,9 @@ const AreaFilters: FC = ({ areas, onFilteredIdsChange }) => { return { icon: , - label: item.group ? item.group.title : 'Ungrouped tags', + label: item.group + ? item.group.title + : messages.areas.filter.ungroupedTagsLabel(), 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 672e251e54..9adf217102 100644 --- a/src/features/areas/components/AreaOverlay/TagsSection.tsx +++ b/src/features/areas/components/AreaOverlay/TagsSection.tsx @@ -7,6 +7,8 @@ import { ZetkinArea } from 'features/areas/types'; import TagManager from 'features/tags/components/TagManager'; import GroupToggle from 'features/tags/components/TagManager/components/GroupToggle'; import ZUIFuture from 'zui/ZUIFuture'; +import { Msg } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; type Props = { area: ZetkinArea; @@ -31,7 +33,9 @@ const TagsSection: FC = ({ area }) => { mb={2} minHeight={38} > - Area tags + + + = ({ /> )} renderPreview={() => ( - - {area.title || 'Untitled area'} - + {area.title} )} value={area.title || ''} /> @@ -172,9 +172,11 @@ const AreaOverlay: FC = ({ } sx={{ overflowWrap: 'anywhere' }} > - {area.description?.trim().length - ? area.description - : 'Empty description'} + {area.description?.trim().length ? ( + area.description + ) : ( + + )} )} @@ -198,22 +200,22 @@ const AreaOverlay: FC = ({ }} variant="contained" > - Save + )} {!editing && ( <> , onSelect: () => { showConfirmDialog({ onSubmit: () => { diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts new file mode 100644 index 0000000000..0f64c40e21 --- /dev/null +++ b/src/features/areas/l10n/messageIds.ts @@ -0,0 +1,34 @@ +import { m, makeMessages } from 'core/i18n'; + +export default makeMessages('feat.areas', { + areas: { + areaSettings: { + delete: m('Delete'), + edit: { + cancelButton: m('Cancel'), + editButton: m('Edit'), + saveButton: m('Save'), + }, + tags: { + title: m('Area tags'), + }, + }, + default: { + description: m('Empty description'), + title: m('Untitled area'), + }, + draw: { + cancelButton: m('Cancel'), + saveButton: m('Save'), + startButton: m('Draw'), + }, + filter: { + addFilterButton: m('Add filter'), + openFiltersButton: m('Filter'), + ungroupedTagsLabel: m('Ungrouped tags'), + }, + }, + page: { + title: m('Geography'), + }, +}); diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 87fc316075..ce503ddbb9 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -3,11 +3,11 @@ import mongoose from 'mongoose'; import { ZetkinArea } from './types'; type ZetkinAreaModelType = { - description: string | null; + description: string; orgId: number; points: ZetkinArea['points']; tags: { id: number; value?: string }[]; - title: string | null; + title: string; }; const areaSchema = new mongoose.Schema({ diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 716281563a..6e10458467 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -3,14 +3,14 @@ import { ZetkinTag } from 'utils/types/zetkin'; export type PointData = [number, number]; export type ZetkinArea = { - description: string | null; + description: string; id: string; organization: { id: number; }; points: PointData[]; tags: ZetkinTag[]; - title: string | null; + title: string; }; export type ZetkinAreaPostBody = Partial>; diff --git a/src/features/breadcrumbs/l10n/messageIds.ts b/src/features/breadcrumbs/l10n/messageIds.ts index 1585d6ed01..7d33908ca0 100644 --- a/src/features/breadcrumbs/l10n/messageIds.ts +++ b/src/features/breadcrumbs/l10n/messageIds.ts @@ -4,13 +4,12 @@ export default makeMessages('feat.breadcrumbs', { elements: { activities: m('Activities'), archive: m('Archive'), - areas: m('Areas'), + areaassignments: m('Area assignments'), assignees: m('Assignees'), calendar: m('Calendar'), callassignments: m('Call assignments'), callers: m('Callers'), campaigns: m('Projects'), - canvassassignments: m('Canvass assignments'), closed: m('Closed'), compose: m('Compose'), conversation: m('Conversation'), @@ -18,6 +17,7 @@ export default makeMessages('feat.breadcrumbs', { emails: m('Emails'), events: m('Events'), folders: m('Lists'), + geography: m('Geography'), incoming: m('Incoming'), insights: m('Insights'), instances: m('Instances'), diff --git a/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx b/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx index 7b7f54b36d..5248e96950 100644 --- a/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx +++ b/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx @@ -18,7 +18,7 @@ import { Msg, useMessages } from 'core/i18n'; import useClusteredActivities, { CLUSTER_TYPE, } from 'features/campaigns/hooks/useClusteredActivities'; -import CanvassAssignmentOverviewListItem from './items/CanvassAssignmentOverviewListItem'; +import AreaAssignmentOverviewListItem from './items/AreaAssignmentOverviewListItem'; type OverviewListProps = { activities: CampaignActivity[]; @@ -63,11 +63,11 @@ const ActivitiesOverviewCard: FC = ({ /> ); - } else if (activity.kind === ACTIVITIES.CANVASS_ASSIGNMENT) { + } else if (activity.kind === ACTIVITIES.AREA_ASSIGNMENT) { return ( {index > 0 && } - diff --git a/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx b/src/features/campaigns/components/ActivitiesOverview/items/AreaAssignmentOverviewListItem.tsx similarity index 57% rename from src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx rename to src/features/campaigns/components/ActivitiesOverview/items/AreaAssignmentOverviewListItem.tsx index dcec47464f..ecb9b9eb0e 100644 --- a/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx +++ b/src/features/campaigns/components/ActivitiesOverview/items/AreaAssignmentOverviewListItem.tsx @@ -1,48 +1,46 @@ import { FC } from 'react'; import { Map, Person } from '@mui/icons-material'; -import { CanvassAssignmentActivity } from 'features/campaigns/types'; +import { AreaAssignmentActivity } from 'features/campaigns/types'; import getStatusColor from 'features/campaigns/utils/getStatusColor'; import OverviewListItem from './OverviewListItem'; -import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; -import getCanvassers from 'features/canvassAssignments/utils/getCanvassers'; +import useAreaAssignmentSessions from 'features/areaAssignments/hooks/useAreaAssignmentSessions'; +import getAreaAssignees from 'features/areaAssignments/utils/getAreaAssignees'; import { useNumericRouteParams } from 'core/hooks'; type Props = { - activity: CanvassAssignmentActivity; + activity: AreaAssignmentActivity; focusDate: Date | null; }; -const CanvassAssignmentOverviewListItem: FC = ({ - activity, - focusDate, -}) => { +const AreaAssignmentOverviewListItem: FC = ({ activity, focusDate }) => { const assignment = activity.data; const { orgId } = useNumericRouteParams(); - const allSessions = useCanvassSessions(orgId, assignment.id).data || []; + const allSessions = + useAreaAssignmentSessions(orgId, assignment.id).data || []; const sessions = allSessions.filter( (session) => session.assignment.id === assignment.id ); - const canvassers = getCanvassers(sessions); + const areaAssignees = getAreaAssignees(sessions); return ( ); }; -export default CanvassAssignmentOverviewListItem; +export default AreaAssignmentOverviewListItem; diff --git a/src/features/campaigns/components/ActivityList/FilterActivities.tsx b/src/features/campaigns/components/ActivityList/FilterActivities.tsx index c92d769d7a..c87e024afd 100644 --- a/src/features/campaigns/components/ActivityList/FilterActivities.tsx +++ b/src/features/campaigns/components/ActivityList/FilterActivities.tsx @@ -30,7 +30,7 @@ const FilterActivities = ({ onSearchStringChange, }: FilterActivitiesProps) => { const messages = useMessages(messageIds); - const hasCanvassing = useFeature(AREAS); + const hasAreaAssignments = useFeature(AREAS); const debouncedFinishedTyping = useDebounce( async (evt: ChangeEvent) => { @@ -78,19 +78,17 @@ const FilterActivities = ({ } label={messages.all.filter.calls()} /> - {hasCanvassing && ( + {hasAreaAssignments && ( } - label={messages.all.filter.canvasses()} + label={messages.all.filter.areaAssignments()} /> )} { ); - } else if (activity.kind == ACTIVITIES.CANVASS_ASSIGNMENT) { + } else if (activity.kind == ACTIVITIES.AREA_ASSIGNMENT) { return ( - + ); } else if (isEventCluster(activity)) { diff --git a/src/features/campaigns/components/ActivityList/items/AreaAssignmentListItem.tsx b/src/features/campaigns/components/ActivityList/items/AreaAssignmentListItem.tsx new file mode 100644 index 0000000000..56a1e5224d --- /dev/null +++ b/src/features/campaigns/components/ActivityList/items/AreaAssignmentListItem.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react'; +import { Map, Person } from '@mui/icons-material'; + +import ActivityListItem, { STATUS_COLORS } from './ActivityListItem'; +import useAreaAssignment from 'features/areaAssignments/hooks/useAreaAssignment'; +import useAreaAssignmentSessions from 'features/areaAssignments/hooks/useAreaAssignmentSessions'; +import getAreaAssignees from 'features/areaAssignments/utils/getAreaAssignees'; + +type Props = { + caId: string; + orgId: number; +}; + +const AreaAssignmentListItem: FC = ({ caId, orgId }) => { + const { data: assignment } = useAreaAssignment(orgId, caId); + + const allSessions = useAreaAssignmentSessions(orgId, caId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === caId + ); + + if (!assignment) { + return null; + } + + const areaAssignees = getAreaAssignees(sessions); + const color = STATUS_COLORS.GRAY; + + return ( + + ); +}; + +export default AreaAssignmentListItem; diff --git a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx deleted file mode 100644 index 0ca6d1ac93..0000000000 --- a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FC } from 'react'; -import { Map, Person } from '@mui/icons-material'; - -import ActivityListItem, { STATUS_COLORS } from './ActivityListItem'; -import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; -import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; -import getCanvassers from 'features/canvassAssignments/utils/getCanvassers'; - -type Props = { - caId: string; - orgId: number; -}; - -const CanvassAssignmentListItem: FC = ({ caId, orgId }) => { - const { data: assignment } = useCanvassAssignment(orgId, caId); - - const allSessions = useCanvassSessions(orgId, caId).data || []; - const sessions = allSessions.filter( - (session) => session.assignment.id === caId - ); - - if (!assignment) { - return null; - } - - const canvassers = getCanvassers(sessions); - const color = STATUS_COLORS.GRAY; - - return ( - - ); -}; - -export default CanvassAssignmentListItem; diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index de53fc074c..c96786eee1 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -15,7 +15,7 @@ import React, { useContext, useState } from 'react'; import CampaignDetailsForm from 'features/campaigns/components/CampaignDetailsForm'; import { DialogContent as CreateTaskDialogContent } from 'zui/ZUISpeedDial/actions/createTask'; -import messageIds from '../l10n/messageIds'; +import campaignMessageIds from '../l10n/messageIds'; import useCampaign from '../hooks/useCampaign'; import useCreateCampaignActivity from '../hooks/useCreateCampaignActivity'; import useCreateEmail from 'features/emails/hooks/useCreateEmail'; @@ -28,9 +28,10 @@ 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/canvassAssignments/hooks/useCreateCanvassAssignment'; +import useCreateAreaAssignment from 'features/areaAssignments/hooks/useCreateAreaAssignment'; import useFeature from 'utils/featureFlags/useFeature'; import { AREAS } from 'utils/featureFlags'; +import areaAssignmentMessageIds from 'features/areaAssignments/l10n/messageIds'; import useEmailConfigs from 'features/emails/hooks/useEmailConfigs'; enum CAMPAIGN_MENU_ITEMS { @@ -46,16 +47,17 @@ interface CampaignActionButtonsProps { const CampaignActionButtons: React.FunctionComponent< CampaignActionButtonsProps > = ({ campaign }) => { - const messages = useMessages(messageIds); + const campaginMessages = useMessages(campaignMessageIds); + const areaAssignmentMessages = useMessages(areaAssignmentMessageIds); const { orgId, campId } = useNumericRouteParams(); - const hasCanvassing = useFeature(AREAS); + const hasAreaAssignments = useFeature(AREAS); // Dialogs const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); const [editCampaignDialogOpen, setEditCampaignDialogOpen] = useState(false); const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false); - const createCanvassAssignment = useCreateCanvassAssignment(orgId); + const createAreaAssignment = useCreateAreaAssignment(orgId); const createEvent = useCreateEvent(orgId); const { createCallAssignment, createSurvey } = useCreateCampaignActivity( orgId, @@ -88,39 +90,39 @@ const CampaignActionButtons: React.FunctionComponent< const menuItems = [ { icon: , - label: messages.createButton.createEvent(), + label: campaginMessages.createButton.createEvent(), onClick: handleCreateEvent, }, { icon: , - label: messages.createButton.createCallAssignment(), + label: campaginMessages.createButton.createCallAssignment(), onClick: () => createCallAssignment({ - title: messages.form.createCallAssignment.newCallAssignment(), + title: campaginMessages.form.createCallAssignment.newCallAssignment(), }), }, { icon: , - label: messages.createButton.createSurvey(), + label: campaginMessages.createButton.createSurvey(), onClick: () => createSurvey({ signature: 'require_signature', - title: messages.form.createSurvey.newSurvey(), + title: campaginMessages.form.createSurvey.newSurvey(), }), }, { icon: , - label: messages.createButton.createTask(), + label: campaginMessages.createButton.createTask(), onClick: () => setCreateTaskDialogOpen(true), }, ]; - if (hasCanvassing) { + if (hasAreaAssignments) { menuItems.push({ icon: , - label: messages.createButton.createCanvassAssignment(), + label: campaginMessages.createButton.createAreaAssignment(), onClick: () => - createCanvassAssignment({ + createAreaAssignment({ campaign_id: campaign.id, instructions: '', metrics: [ @@ -128,10 +130,11 @@ const CampaignActionButtons: React.FunctionComponent< definesDone: true, description: '', kind: 'boolean', - question: messages.form.createCanvassAssignment.defaultQuestion(), + question: + campaginMessages.form.createAreaAssignment.defaultQuestion(), }, ], - title: null, + title: areaAssignmentMessages.default.title(), }), }); } @@ -139,13 +142,13 @@ const CampaignActionButtons: React.FunctionComponent< if (configs.length && themes.length > 0) { menuItems.push({ icon: , - label: messages.createButton.createEmail(), + label: campaginMessages.createButton.createEmail(), onClick: () => createEmail({ campaign_id: campId, config_id: configs[0].id, theme_id: themes[0].id, - title: messages.form.createEmail.newEmail(), + title: campaginMessages.form.createEmail.newEmail(), }), }); } @@ -155,7 +158,7 @@ const CampaignActionButtons: React.FunctionComponent< @@ -166,7 +169,7 @@ const CampaignActionButtons: React.FunctionComponent< label: ( <> - + ), onSelect: () => setEditCampaignDialogOpen(true), @@ -176,14 +179,14 @@ const CampaignActionButtons: React.FunctionComponent< label: ( <> - + ), onSelect: () => { showConfirmDialog({ onSubmit: deleteCampaign, - title: messages.form.deleteCampaign.title(), - warningText: messages.form.deleteCampaign.warning(), + title: campaginMessages.form.deleteCampaign.title(), + warningText: campaginMessages.form.deleteCampaign.warning(), }); }, }, @@ -200,7 +203,11 @@ const CampaignActionButtons: React.FunctionComponent< target="_blank" underline="none" > - {} + { + + } ), @@ -214,7 +221,7 @@ const CampaignActionButtons: React.FunctionComponent< setEditCampaignDialogOpen(false)} open={editCampaignDialogOpen} - title={messages.form.edit()} + title={campaginMessages.form.edit()} > { diff --git a/src/features/campaigns/hooks/useActivityArchive.ts b/src/features/campaigns/hooks/useActivityArchive.ts index 2650cb9867..f26e3faeca 100644 --- a/src/features/campaigns/hooks/useActivityArchive.ts +++ b/src/features/campaigns/hooks/useActivityArchive.ts @@ -1,4 +1,4 @@ -import useCanvassAssignmentActivities from 'features/canvassAssignments/hooks/useCanvassAssignmentActivities'; +import useAreaAssignmentActivities from 'features/areaAssignments/hooks/useAreaAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; @@ -17,7 +17,7 @@ export default function useActivityArchive( campId?: number ): IFuture { const surveyActivitiesFuture = useSurveyActivities(orgId, campId); - const canvassAssignmentActivitiesFuture = useCanvassAssignmentActivities( + const areaAssignmentActivitiesFuture = useAreaAssignmentActivities( orgId, campId ); @@ -31,7 +31,7 @@ export default function useActivityArchive( if ( callAssignmentActivitiesFuture.isLoading || - canvassAssignmentActivitiesFuture.isLoading || + areaAssignmentActivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || eventActivitiesFuture.isLoading || @@ -40,7 +40,7 @@ export default function useActivityArchive( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || - canvassAssignmentActivitiesFuture.error || + areaAssignmentActivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || eventActivitiesFuture.error || @@ -53,7 +53,7 @@ export default function useActivityArchive( activities.push( ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), - ...(canvassAssignmentActivitiesFuture.data || []), + ...(areaAssignmentActivitiesFuture.data || []), ...(taskActivitiesFuture.data || []), ...(eventActivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) diff --git a/src/features/campaigns/hooks/useActivityList.ts b/src/features/campaigns/hooks/useActivityList.ts index cdc92acc61..467b25e0ba 100644 --- a/src/features/campaigns/hooks/useActivityList.ts +++ b/src/features/campaigns/hooks/useActivityList.ts @@ -1,4 +1,4 @@ -import useCanvassAssignmentActivities from 'features/canvassAssignments/hooks/useCanvassAssignmentActivities'; +import useAreaAssignmentActivities from 'features/areaAssignments/hooks/useAreaAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; @@ -21,7 +21,7 @@ export default function useActivityList( orgId, campId ); - const canvassAssignmentActivitiesFuture = useCanvassAssignmentActivities( + const areaAssignmentActivitiesFuture = useAreaAssignmentActivities( orgId, campId ); @@ -31,7 +31,7 @@ export default function useActivityList( if ( callAssignmentActivitiesFuture.isLoading || - canvassAssignmentActivitiesFuture.isLoading || + areaAssignmentActivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || eventActivitiesFuture.isLoading || @@ -40,7 +40,7 @@ export default function useActivityList( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || - canvassAssignmentActivitiesFuture.error || + areaAssignmentActivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || eventActivitiesFuture.error || @@ -53,7 +53,7 @@ export default function useActivityList( activities.push( ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), - ...(canvassAssignmentActivitiesFuture.data || []), + ...(areaAssignmentActivitiesFuture.data || []), ...(taskActivitiesFuture.data || []), ...(eventActivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) diff --git a/src/features/campaigns/hooks/useActivityOverview.ts b/src/features/campaigns/hooks/useActivityOverview.ts index 9ead952104..121340ac49 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/canvassAssignments/hooks/useCanvassAssignmentActivities'; +import useAreaAssignmentActivities from 'features/areaAssignments/hooks/useAreaAssignmentActivities'; import useEmailActivities from './useEmailActivities'; import useEventsFromDateRange from 'features/events/hooks/useEventsFromDateRange'; import useSurveyActivities from './useSurveyActivities'; @@ -34,14 +34,14 @@ export default function useActivitiyOverview( campId ); const emailActivitiesFuture = useEmailActivities(orgId, campId); - const canvassAssignmentAcitivitiesFuture = useCanvassAssignmentActivities( + const areaAssignmentAcitivitiesFuture = useAreaAssignmentActivities( orgId, campId ); if ( callAssignmentActivitiesFuture.isLoading || - canvassAssignmentAcitivitiesFuture.isLoading || + areaAssignmentAcitivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || emailActivitiesFuture.isLoading @@ -49,7 +49,7 @@ export default function useActivitiyOverview( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || - canvassAssignmentAcitivitiesFuture.error || + areaAssignmentAcitivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || emailActivitiesFuture.error @@ -63,7 +63,7 @@ export default function useActivitiyOverview( ...(taskActivitiesFuture.data || []), ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), - ...(canvassAssignmentAcitivitiesFuture.data || []), + ...(areaAssignmentAcitivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) ); diff --git a/src/features/campaigns/hooks/useClusteredActivities.ts b/src/features/campaigns/hooks/useClusteredActivities.ts index 2f29c87a49..6b955719e9 100644 --- a/src/features/campaigns/hooks/useClusteredActivities.ts +++ b/src/features/campaigns/hooks/useClusteredActivities.ts @@ -4,7 +4,7 @@ import { ACTIVITIES, CallAssignmentActivity, CampaignActivity, - CanvassAssignmentActivity, + AreaAssignmentActivity, EmailActivity, EventActivity, SurveyActivity, @@ -41,7 +41,7 @@ export type ClusteredEvent = export type NonEventActivity = | CallAssignmentActivity - | CanvassAssignmentActivity + | AreaAssignmentActivity | SurveyActivity | TaskActivity | EmailActivity; diff --git a/src/features/campaigns/l10n/messageIds.ts b/src/features/campaigns/l10n/messageIds.ts index 13412c165e..a5da70b850 100644 --- a/src/features/campaigns/l10n/messageIds.ts +++ b/src/features/campaigns/l10n/messageIds.ts @@ -39,8 +39,8 @@ export default makeMessages('feat.campaigns', { cardCTA: m('Go to project'), create: m('Create new project'), filter: { + areaAssignments: m('Area assignments'), calls: m('Call assignments'), - canvasses: m('Canvass assignments'), emails: m('Emails'), filter: m('Filter results'), standalones: m('Standalone events'), @@ -61,8 +61,8 @@ export default makeMessages('feat.campaigns', { calendarView: m('See all in calendar'), createButton: { createActivity: m('Create'), + createAreaAssignment: m('Area assignment'), createCallAssignment: m('Call assignment'), - createCanvassAssignment: m('Canvass assignment'), createEmail: m('Email'), createEvent: m('Event'), createSurvey: m('Survey'), @@ -77,6 +77,9 @@ export default makeMessages('feat.campaigns', { heading: m('Feedback and Surveys (none configured)'), }, form: { + createAreaAssignment: { + defaultQuestion: m('Did you complete the task?'), + }, createCallAssignment: { newCallAssignment: m('My call assignment'), }, @@ -85,9 +88,6 @@ 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'), }, @@ -140,8 +140,8 @@ export default makeMessages('feat.campaigns', { }, linkGroup: { createActivity: m('Create'), + createAreaAssignment: m('Area assignment'), createCallAssignment: m('Call assignment'), - createCanvassAssignment: m('Canvass assignment'), createEmail: m('Email'), createEvent: m('Event'), createSurvey: m('Survey'), diff --git a/src/features/campaigns/types.ts b/src/features/campaigns/types.ts index 7ed1ca2ebf..5e1c97404d 100644 --- a/src/features/campaigns/types.ts +++ b/src/features/campaigns/types.ts @@ -1,4 +1,4 @@ -import { ZetkinCanvassAssignment } from 'features/canvassAssignments/types'; +import { ZetkinAreaAssignment } from 'features/areaAssignments/types'; import { ZetkinCallAssignment, ZetkinEmail, @@ -9,7 +9,7 @@ import { export enum ACTIVITIES { CALL_ASSIGNMENT = 'callAssignment', - CANVASS_ASSIGNMENT = 'canvassAssignment', + AREA_ASSIGNMENT = 'areaAssignment', EMAIL = 'email', EVENT = 'event', SURVEY = 'survey', @@ -26,9 +26,9 @@ export type CallAssignmentActivity = CampaignActivityBase & { kind: ACTIVITIES.CALL_ASSIGNMENT; }; -export type CanvassAssignmentActivity = CampaignActivityBase & { - data: ZetkinCanvassAssignment; - kind: ACTIVITIES.CANVASS_ASSIGNMENT; +export type AreaAssignmentActivity = CampaignActivityBase & { + data: ZetkinAreaAssignment; + kind: ACTIVITIES.AREA_ASSIGNMENT; }; export type SurveyActivity = CampaignActivityBase & { @@ -53,7 +53,7 @@ export type EmailActivity = CampaignActivityBase & { export type CampaignActivity = | CallAssignmentActivity - | CanvassAssignmentActivity + | AreaAssignmentActivity | EmailActivity | EventActivity | SurveyActivity diff --git a/src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx b/src/features/canvass/components/CanvassInstructionsPage.tsx similarity index 81% rename from src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx rename to src/features/canvass/components/CanvassInstructionsPage.tsx index a1a4aceb28..763b1a0928 100644 --- a/src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx +++ b/src/features/canvass/components/CanvassInstructionsPage.tsx @@ -13,19 +13,21 @@ import { } from '@mui/material'; import { useRouter } from 'next/navigation'; -import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; -import { ZetkinCanvassAssignment } from '../types'; +import useMyCanvassAssignments from '../hooks/useMyAreaAssignments'; +import { ZetkinAreaAssignment } from '../../areaAssignments/types'; import ZUIMarkdown from 'zui/ZUIMarkdown'; import useSidebarStats from '../hooks/useSidebarStats'; -import useCanvassSessions from '../hooks/useCanvassSessions'; import useMembership from 'features/organizations/hooks/useMembership'; -import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; import theme from 'theme'; +import useAreaAssignmentSessions from 'features/areaAssignments/hooks/useAreaAssignmentSessions'; +import useAreaAssignmentStats from 'features/areaAssignments/hooks/useAreaAssignmentStats'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; -const InstructionsPage: FC<{ - assignment: ZetkinCanvassAssignment; +const Page: FC<{ + assignment: ZetkinAreaAssignment; }> = ({ assignment }) => { const orgFuture = useOrganization(assignment.organization.id); const router = useRouter(); @@ -34,13 +36,14 @@ const InstructionsPage: FC<{ assignment.id ); - const { data } = useCanvassAssignmentStats( + const { data } = useAreaAssignmentStats( assignment.organization.id, assignment.id ); const allSessions = - useCanvassSessions(assignment.organization.id, assignment.id).data || []; + useAreaAssignmentSessions(assignment.organization.id, assignment.id).data || + []; const membershipFuture = useMembership(assignment.organization.id); const userPersonId = membershipFuture.data?.profile.id; @@ -72,9 +75,7 @@ const InstructionsPage: FC<{ padding={2} > - - {assignment.title ?? 'Untitled canvassassignment'} - + {assignment.title} - Areas + @@ -141,14 +142,14 @@ const InstructionsPage: FC<{ - Households visited + - {stats.allTime.numPlaces} + {stats.allTime.numLocations} - {data?.num_places} + {data?.num_locations} - Places visited + )} - {assignment.instructions ? ( - Instructions + + + - You are ready to go + )} @@ -225,7 +227,7 @@ const InstructionsPage: FC<{ }} variant="contained" > - Start Canvassing + @@ -234,23 +236,23 @@ const InstructionsPage: FC<{ ); }; -type MyCanvassInstructionsPageProps = { - canvassAssId: string; +type CanvassInstructionsPageProps = { + areaAssId: string; }; -const MyCanvassInstructionsPage: FC = ({ - canvassAssId, +const CanvassInstructionsPage: FC = ({ + areaAssId, }) => { const myAssignments = useMyCanvassAssignments() || []; const assignment = myAssignments.find( - (assignment) => assignment.id == canvassAssId + (assignment) => assignment.id == areaAssId ); if (!assignment) { return null; } - return ; + return ; }; -export default MyCanvassInstructionsPage; +export default CanvassInstructionsPage; diff --git a/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx b/src/features/canvass/components/CanvassMap.tsx similarity index 78% rename from src/features/canvassAssignments/components/CanvassAssignmentMap.tsx rename to src/features/canvass/components/CanvassMap.tsx index 403363ffbb..7a9cfa93ae 100644 --- a/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx +++ b/src/features/canvass/components/CanvassMap.tsx @@ -21,16 +21,16 @@ import { import { ZetkinArea } from '../../areas/types'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; -import useCreatePlace from '../hooks/useCreatePlace'; -import usePlaces from '../hooks/usePlaces'; +import useCreateLocation from '../hooks/useCreateLocation'; +import useLocations from 'features/areaAssignments/hooks/useLocations'; import getCrosshairPositionOnMap from '../utils/getCrosshairPositionOnMap'; import getVisitState from '../utils/getVisitState'; import MarkerIcon from '../utils/markerIcon'; -import { ZetkinCanvassAssignment } from '../types'; -import MapControls from './MapControls'; +import { ZetkinAreaAssignment } from 'features/areaAssignments/types'; +import MapControls from 'features/areaAssignments/components/MapControls'; import objToLatLng from 'features/areas/utils/objToLatLng'; -import CanvassAssignmentMapOverlays from './CanvassAssignmentMapOverlays'; -import useAllPlaceVisits from '../hooks/useAllPlaceVisits'; +import CanvassMapOverlays from './CanvassMapOverlays'; +import useAllLocationVisits from '../hooks/useAllLocationVisits'; import useLocalStorage from 'zui/hooks/useLocalStorage'; const useStyles = makeStyles(() => ({ @@ -63,20 +63,17 @@ const useStyles = makeStyles(() => ({ }, })); -type CanvassAssignmentMapProps = { +type CanvassMapProps = { areas: ZetkinArea[]; - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; }; -const CanvassAssignmentMap: FC = ({ - areas, - assignment, -}) => { +const CanvassMap: FC = ({ areas, assignment }) => { const theme = useTheme(); const classes = useStyles(); - const places = usePlaces(assignment.organization.id).data || []; - const createPlace = useCreatePlace(assignment.organization.id); - const placeVisitList = useAllPlaceVisits( + const locations = useLocations(assignment.organization.id).data || []; + const createLocation = useCreateLocation(assignment.organization.id); + const locationVisitList = useAllLocationVisits( assignment.organization.id, assignment.id ); @@ -86,14 +83,18 @@ const CanvassAssignmentMap: FC = ({ const [map, setMap] = useState(null); const [zoomed, setZoomed] = useState(false); - const [selectedPlaceId, setSelectedPlaceId] = useState(null); + const [selectedLocationId, setSelectedLocationId] = useState( + null + ); const [isCreating, setIsCreating] = useState(false); const [created, setCreated] = useState(false); const crosshairRef = useRef(null); const reactFGref = useRef(null); - const selectedPlace = places.find((place) => place.id == selectedPlaceId); + const selectedLocation = locations.find( + (location) => location.id == selectedLocationId + ); const saveBounds = () => { const bounds = map?.getBounds(); @@ -107,12 +108,12 @@ const CanvassAssignmentMap: FC = ({ }; const updateSelection = useCallback(() => { - let nearestPlace: string | null = null; + let nearestLocation: string | null = null; let nearestDistance = Infinity; if (isCreating) { - if (selectedPlaceId) { - setSelectedPlaceId(null); + if (selectedLocationId) { + setSelectedLocationId(null); } return; } @@ -123,30 +124,30 @@ const CanvassAssignmentMap: FC = ({ if (map && crosshair) { const markerPos = getCrosshairPositionOnMap(map, crosshair); - places.forEach((place) => { - const screenPos = map.latLngToContainerPoint(place.position); + locations.forEach((location) => { + const screenPos = map.latLngToContainerPoint(location.position); const dx = screenPos.x - markerPos.markerX; const dy = screenPos.y - markerPos.markerY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < nearestDistance) { nearestDistance = dist; - nearestPlace = place.id; + nearestLocation = location.id; } }); if (nearestDistance < 20) { - if (nearestPlace != selectedPlace) { - setSelectedPlaceId(nearestPlace); + if (nearestLocation != selectedLocation) { + setSelectedLocationId(nearestLocation); } } else { - setSelectedPlaceId(null); + setSelectedLocationId(null); } } } catch (err) { // Do nothing for now } - }, [map, selectedPlaceId, places]); + }, [map, selectedLocationId, locations]); const panTo = useCallback( (pos: LatLng) => { @@ -175,7 +176,7 @@ const CanvassAssignmentMap: FC = ({ if (created) { updateSelection(); } - }, [created, places]); + }, [created, locations]); useEffect(() => { if (map) { @@ -197,7 +198,7 @@ const CanvassAssignmentMap: FC = ({ map.off('zoomend', saveBounds); }; } - }, [map, selectedPlaceId, places, panTo, updateSelection]); + }, [map, selectedLocationId, locations, panTo, updateSelection]); useEffect(() => { if (map && !zoomed) { @@ -247,10 +248,10 @@ const CanvassAssignmentMap: FC = ({ ref={crosshairRef} className={classes.crosshair} sx={{ - opacity: !selectedPlaceId ? 1 : 0.3, + opacity: !selectedLocationId ? 1 : 0.3, }} > - {!selectedPlaceId && isCreating && ( + {!selectedLocationId && isCreating && ( = ({ /> ))} - {places.map((place) => { - const householdState = getVisitState(place.households, assignment.id); - const visited = placeVisitList.data?.some( - (visit) => visit.placeId == place.id + {locations.map((location) => { + const householdState = getVisitState( + location.households, + assignment.id + ); + const visited = locationVisitList.data?.some( + (visit) => visit.locationId == location.id ); const state = visited ? 'all' : householdState; - const selected = place.id == selectedPlaceId; - const key = `marker-${place.id}-${selected.toString()}`; + const selected = location.id == selectedLocationId; + const key = `marker-${location.id}-${selected.toString()}`; return ( = ({ }} iconAnchor={[11, 33]} position={{ - lat: place.position.lat, - lng: place.position.lng, + lat: location.position.lat, + lng: location.position.lng, }} > = ({ ); })} - { @@ -338,7 +342,7 @@ const CanvassAssignmentMap: FC = ({ ]); if (point) { setCreated(true); - createPlace({ + createLocation({ position: point, title, }); @@ -346,10 +350,10 @@ const CanvassAssignmentMap: FC = ({ } }} onToggleCreating={(creating) => setIsCreating(creating)} - selectedPlace={selectedPlace || null} + selectedLocation={selectedLocation || null} /> ); }; -export default CanvassAssignmentMap; +export default CanvassMap; diff --git a/src/features/canvassAssignments/components/CanvassAssignmentMapOverlays.tsx b/src/features/canvass/components/CanvassMapOverlays.tsx similarity index 64% rename from src/features/canvassAssignments/components/CanvassAssignmentMapOverlays.tsx rename to src/features/canvass/components/CanvassMapOverlays.tsx index 051faa35a4..4851720648 100644 --- a/src/features/canvassAssignments/components/CanvassAssignmentMapOverlays.tsx +++ b/src/features/canvass/components/CanvassMapOverlays.tsx @@ -2,17 +2,22 @@ import { Box, Button } from '@mui/material'; import { FC, useEffect, useState } from 'react'; import { makeStyles } from '@mui/styles'; -import { ZetkinCanvassAssignment, ZetkinPlace } from '../types'; -import PlaceDialog from './PlaceDialog'; -import { CreatePlaceCard } from './CreatePlaceCard'; -import ContractedHeader from './PlaceDialog/ContractedHeader'; +import { + ZetkinAreaAssignment, + ZetkinLocation, +} from 'features/areaAssignments/types'; +import LocationDialog from './LocationDialog'; +import { CreateLocationCard } from './CreateLocationCard'; +import ContractedHeader from './LocationDialog/ContractedHeader'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; type Props = { - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; isCreating: boolean; onCreate: (title: string) => void; onToggleCreating: (creating: boolean) => void; - selectedPlace: ZetkinPlace | null; + selectedLocation: ZetkinLocation | null; }; const useStyles = makeStyles(() => ({ @@ -28,20 +33,20 @@ const useStyles = makeStyles(() => ({ }, })); -const CanvassAssignmentMapOverlays: FC = ({ +const CanvassMapOverlays: FC = ({ assignment, isCreating, onCreate, onToggleCreating, - selectedPlace, + selectedLocation, }) => { const [expanded, setExpanded] = useState(false); const classes = useStyles(); - const showViewPlaceButton = !!selectedPlace && !expanded; + const showViewLocationButton = !!selectedLocation && !expanded; let drawerTop = '100dvh'; - if (selectedPlace) { + if (selectedLocation) { if (expanded) { drawerTop = '6rem'; } else { @@ -52,17 +57,17 @@ const CanvassAssignmentMapOverlays: FC = ({ } useEffect(() => { - if (!selectedPlace && expanded) { + if (!selectedLocation && expanded) { setExpanded(false); } - }, [selectedPlace]); + }, [selectedLocation]); return ( <> - {!selectedPlace && !isCreating && ( + {!selectedLocation && !isCreating && ( )} @@ -79,24 +84,27 @@ const CanvassAssignmentMapOverlays: FC = ({ zIndex: 10001, })} > - {showViewPlaceButton && ( + {showViewLocationButton && ( setExpanded(true)} p={2}> - + )} - {selectedPlace && expanded && ( - { setExpanded(false); }} orgId={assignment.organization.id} - place={selectedPlace} /> )} {isCreating && ( - { onToggleCreating(false); }} @@ -111,4 +119,4 @@ const CanvassAssignmentMapOverlays: FC = ({ ); }; -export default CanvassAssignmentMapOverlays; +export default CanvassMapOverlays; diff --git a/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx b/src/features/canvass/components/CanvassPage.tsx similarity index 75% rename from src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx rename to src/features/canvass/components/CanvassPage.tsx index b0514c13b1..80d64fcc3b 100644 --- a/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx +++ b/src/features/canvass/components/CanvassPage.tsx @@ -8,17 +8,15 @@ import { Menu } from '@mui/icons-material'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; import useServerSide from 'core/useServerSide'; -import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; +import useMyAreaAssignments from '../hooks/useMyAreaAssignments'; import { AssignmentWithAreas } from '../types'; -import CanvasserSidebar from './CanvasserSidebar'; +import CanvassSidebar from './CanvassSidebar'; -const CanvassAssignmentMap = dynamic(() => import('./CanvassAssignmentMap'), { +const CanvassMap = dynamic(() => import('./CanvassMap'), { ssr: false, }); -const AssignmentPage: FC<{ assignment: AssignmentWithAreas }> = ({ - assignment, -}) => { +const Page: FC<{ assignment: AssignmentWithAreas }> = ({ assignment }) => { const orgFuture = useOrganization(assignment.organization.id); const isServer = useServerSide(); const [showMenu, setShowMenu] = useState(false); @@ -59,9 +57,7 @@ const AssignmentPage: FC<{ assignment: AssignmentWithAreas }> = ({ > - - {assignment.title ?? 'Untitled canvassassignment'} - + {assignment.title} = ({ - + setShowMenu(false)} @@ -110,7 +103,7 @@ const AssignmentPage: FC<{ assignment: AssignmentWithAreas }> = ({ zIndex: 99999, }} > - + )} @@ -118,23 +111,21 @@ const AssignmentPage: FC<{ assignment: AssignmentWithAreas }> = ({ ); }; -type MyCanvassAssignmentPageProps = { - canvassAssId: string; +type CanvassPageProps = { + areaAssId: string; }; -const MyCanvassAssignmentPage: FC = ({ - canvassAssId, -}) => { - const myAssignments = useMyCanvassAssignments(); +const CanvassPage: FC = ({ areaAssId }) => { + const myAssignments = useMyAreaAssignments(); const assignment = myAssignments.find( - (assignment) => assignment.id == canvassAssId + (assignment) => assignment.id == areaAssId ); if (!assignment) { return null; } - return ; + return ; }; -export default MyCanvassAssignmentPage; +export default CanvassPage; diff --git a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx b/src/features/canvass/components/CanvassSidebar/index.tsx similarity index 56% rename from src/features/canvassAssignments/components/CanvasserSidebar/index.tsx rename to src/features/canvass/components/CanvassSidebar/index.tsx index 196bbb6b94..8dbb1ceee9 100644 --- a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx +++ b/src/features/canvass/components/CanvassSidebar/index.tsx @@ -11,16 +11,18 @@ import { Typography, } from '@mui/material'; -import { ZetkinCanvassAssignment } from '../../types'; -import useSidebarStats from 'features/canvassAssignments/hooks/useSidebarStats'; +import { ZetkinAreaAssignment } from '../../../areaAssignments/types'; +import useSidebarStats from 'features/canvass/hooks/useSidebarStats'; import ZUIMarkdown from 'zui/ZUIMarkdown'; import ZUIRelativeTime from 'zui/ZUIRelativeTime'; +import { Msg } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; type Props = { - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; }; -const CanvasserSidebar: FC = ({ assignment }) => { +const CanvassSidebar: FC = ({ assignment }) => { const { loading, stats, sync, synced } = useSidebarStats( assignment.organization.id, assignment.id @@ -51,32 +53,46 @@ const CanvasserSidebar: FC = ({ assignment }) => { {assignment.title} - Progress + + + - Households + + + - Places + + + ({ bgcolor: theme.palette.grey[100] })} /> - Session (today) - You + + + + + + {stats.today.numUserHouseholds} - {stats.today.numUserPlaces} - Team + {stats.today.numUserLocations} + + + {stats.today.numHouseholds} - {stats.today.numPlaces} + {stats.today.numLocations} ({ bgcolor: theme.palette.grey[100] })} /> - All time + + + {stats.allTime.numHouseholds} - {stats.allTime.numPlaces} + {stats.allTime.numLocations} = ({ assignment }) => { {!loading && synced && ( - <> - Synced - + , + }} + /> + )} + {!loading && !synced && ( + )} - {!loading && !synced && `Never synced`} @@ -101,7 +122,13 @@ const CanvasserSidebar: FC = ({ assignment }) => { onClick={() => sync()} startIcon={loading ? : null} > - {loading ? 'Syncing' : 'Sync now'} + @@ -112,7 +139,10 @@ const CanvasserSidebar: FC = ({ assignment }) => { {assignment.instructions && ( - + } + sx={{ pb: 2 }} + /> ({ bgcolor: theme.palette.grey[100] })} /> @@ -121,14 +151,18 @@ const CanvasserSidebar: FC = ({ assignment }) => { )} - + } + /> - + } + /> ); }; -export default CanvasserSidebar; +export default CanvassSidebar; diff --git a/src/features/canvassAssignments/components/CreatePlaceCard.tsx b/src/features/canvass/components/CreateLocationCard.tsx similarity index 69% rename from src/features/canvassAssignments/components/CreatePlaceCard.tsx rename to src/features/canvass/components/CreateLocationCard.tsx index f8410ae55d..c4b45b1857 100644 --- a/src/features/canvassAssignments/components/CreatePlaceCard.tsx +++ b/src/features/canvass/components/CreateLocationCard.tsx @@ -1,39 +1,41 @@ import { FC, useState } from 'react'; import { Box, Button, FormControl, TextField } from '@mui/material'; -type AddPlaceDialogProps = { +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type CreateLocationCardProps = { onClose: () => void; onCreate: (title: string) => void; }; -export const CreatePlaceCard: FC = ({ +export const CreateLocationCard: FC = ({ onClose, onCreate, }) => { + const messages = useMessages(messageIds); const [title, setTitle] = useState(''); return (
{ ev.preventDefault(); - if (title) { - onCreate(title); - onClose(); - } + onCreate(title || messages.default.location()); + onClose(); }} > setTitle(ev.target.value)} - placeholder="Type a name for the place" + placeholder={messages.map.addLocation.inputPlaceholder()} sx={{ paddingTop: 1 }} /> @@ -48,7 +50,7 @@ export const CreatePlaceCard: FC = ({ type="submit" variant="contained" > - Create place + diff --git a/src/features/canvassAssignments/components/EncouragingSparkle.tsx b/src/features/canvass/components/EncouragingSparkle.tsx similarity index 100% rename from src/features/canvassAssignments/components/EncouragingSparkle.tsx rename to src/features/canvass/components/EncouragingSparkle.tsx diff --git a/src/features/canvass/components/LocationDialog/ContractedHeader.tsx b/src/features/canvass/components/LocationDialog/ContractedHeader.tsx new file mode 100644 index 0000000000..fa7f6d42ae --- /dev/null +++ b/src/features/canvass/components/LocationDialog/ContractedHeader.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { IconButton } from '@mui/material'; +import { KeyboardArrowUp } from '@mui/icons-material'; + +import PageBaseHeader from './pages/PageBaseHeader'; +import useLocationVisits from 'features/canvass/hooks/useLocationVisits'; +import { + ZetkinAreaAssignment, + ZetkinLocation, +} from 'features/areaAssignments/types'; +import estimateVisitedHouseholds from 'features/canvass/utils/estimateVisitedHouseholds'; +import { useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; + +type Props = { + assignment: ZetkinAreaAssignment; + location: ZetkinLocation; +}; + +const ContractedHeader: FC = ({ assignment, location }) => { + const messages = useMessages(messageIds); + + const visitsFuture = useLocationVisits( + assignment.organization.id, + assignment.id, + location.id + ); + + const numHouseholdsVisitedIndividually = + location?.households.filter((household) => + household.visits.some((visit) => visit.areaAssId == assignment.id) + ).length ?? 0; + + const numHouseholdsPerLocationVisit = + visitsFuture.data?.map(estimateVisitedHouseholds) ?? []; + + const numVisitedHouseholds = Math.max( + numHouseholdsVisitedIndividually, + ...numHouseholdsPerLocationVisit + ); + + const numHouseholds = Math.max( + location.households.length, + numVisitedHouseholds + ); + + return ( + + + + } + subtitle={messages.location.header({ + numHouseholds, + numVisitedHouseholds, + })} + title={location.title} + /> + ); +}; + +export default ContractedHeader; diff --git a/src/features/canvassAssignments/components/PlaceDialog/IntInput.tsx b/src/features/canvass/components/LocationDialog/IntInput.tsx similarity index 100% rename from src/features/canvassAssignments/components/PlaceDialog/IntInput.tsx rename to src/features/canvass/components/LocationDialog/IntInput.tsx diff --git a/src/features/canvassAssignments/components/PlaceDialog/index.tsx b/src/features/canvass/components/LocationDialog/index.tsx similarity index 68% rename from src/features/canvassAssignments/components/PlaceDialog/index.tsx rename to src/features/canvass/components/LocationDialog/index.tsx index 550fe7169b..dfbd081387 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/index.tsx +++ b/src/features/canvass/components/LocationDialog/index.tsx @@ -1,54 +1,54 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { Box } from '@mui/material'; -import VisitWizard from './pages/VisitWizard'; -import EditPlace from './pages/EditPlace'; -import Place from './pages/Place'; -import Household from './pages/Household'; +import HouseholdVisitPage from './pages/HouseholdVisitPage'; +import EditLocationPage from './pages/EditLocationPage'; +import LocationPage from './pages/LocationPage'; +import HouseholdPage from './pages/HouseholdPage'; import { - ZetkinCanvassAssignment, - ZetkinPlace, -} from 'features/canvassAssignments/types'; -import usePlaceMutations from 'features/canvassAssignments/hooks/usePlaceMutations'; + ZetkinAreaAssignment, + ZetkinLocation, +} from 'features/areaAssignments/types'; import ZUINavStack from 'zui/ZUINavStack'; -import EditHousehold from './pages/EditHousehold'; +import EditHouseholdPage from './pages/EditHouseholdPage'; import CreateHouseholdsPage from './pages/CreateHouseholdsPage'; -import EncouragingSparkle from '../EncouragingSparkle'; -import PlaceVisitPage from './pages/PlaceVisitPage'; +import LocationVisitPage from './pages/LocationVisitPage'; import HouseholdsPage from './pages/HouseholdsPage'; +import useLocationMutations from 'features/canvass/hooks/useLocationMutations'; +import EncouragingSparkle from '../EncouragingSparkle'; -type PlaceDialogProps = { - assignment: ZetkinCanvassAssignment; +type LocationDialogProps = { + assignment: ZetkinAreaAssignment; + location: ZetkinLocation; onClose: () => void; orgId: number; - place: ZetkinPlace; }; -type PlaceDialogStep = - | 'place' +type LocationDialogStep = + | 'location' | 'edit' | 'createHouseholds' | 'households' | 'household' | 'editHousehold' - | 'placeVisit' - | 'wizard'; + | 'locationVisit' + | 'householdVisit'; -const PlaceDialog: FC = ({ +const LocationDialog: FC = ({ assignment, onClose, orgId, - place, + location, }) => { - const [dialogStep, setDialogStep] = useState('place'); + const [dialogStep, setDialogStep] = useState('location'); const [showSparkle, setShowSparkle] = useState(false); - const { addVisit, reportPlaceVisit, updateHousehold, updatePlace } = - usePlaceMutations(orgId, place.id); + const { addVisit, reportLocationVisit, updateHousehold, updateLocation } = + useLocationMutations(orgId, location.id); const pushedRef = useRef(false); const goto = useCallback( - (step: PlaceDialogStep) => { + (step: LocationDialogStep) => { setDialogStep(step); history.pushState({ step: step }, '', `?step=${step}`); }, @@ -80,7 +80,7 @@ const PlaceDialog: FC = ({ useEffect(() => { if (!pushedRef.current) { pushedRef.current = true; - goto('place'); + goto('location'); } }, []); @@ -88,7 +88,7 @@ const PlaceDialog: FC = ({ null ); - const selectedHousehold = place.households.find( + const selectedHousehold = location.households.find( (household) => household.id == selectedHouseholdId ); @@ -98,27 +98,28 @@ const PlaceDialog: FC = ({ setShowSparkle(false)} /> )} - goto('edit')} onHouseholds={() => goto('households')} - onVisit={() => goto('placeVisit')} - place={place} + onVisit={() => goto('locationVisit')} /> - back()} onClose={onClose} onSave={async (title, description) => { - await updatePlace({ description, title }); + await updateLocation({ description, title }); back(); }} - place={place} /> back()} onBulk={() => goto('createHouseholds')} onClose={onClose} @@ -131,35 +132,34 @@ const PlaceDialog: FC = ({ goto('household'); }} orgId={orgId} - place={place} /> {selectedHousehold && ( - back()} onClose={onClose} onEdit={() => goto('editHousehold')} - onWizardStart={() => { - goto('wizard'); + onHouseholdVisitStart={() => { + goto('householdVisit'); }} visitedInThisAssignment={selectedHousehold.visits.some( - (visit) => visit.canvassAssId == assignment.id + (visit) => visit.areaAssId == assignment.id )} /> )} back()} onClose={onClose} orgId={orgId} - placeId={place.id} /> {selectedHousehold && ( - back()} onClose={onClose} @@ -170,31 +170,31 @@ const PlaceDialog: FC = ({ /> )} - - + back()} onClose={onClose} onLogVisit={async (responses) => { - await reportPlaceVisit(assignment.id, { - canvassAssId: assignment.id, - placeId: place.id, + await reportLocationVisit(assignment.id, { + areaAssId: assignment.id, + locationId: location.id, responses, }); setShowSparkle(true); }} /> - + {selectedHousehold && ( - back()} onLogVisit={async (responses, noteToOfficial) => { await addVisit(selectedHousehold.id, { - canvassAssId: assignment.id, + areaAssId: assignment.id, noteToOfficial, responses, timestamp: new Date().toISOString(), @@ -210,4 +210,4 @@ const PlaceDialog: FC = ({ ); }; -export default PlaceDialog; +export default LocationDialog; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/CreateHouseholdsPage.tsx b/src/features/canvass/components/LocationDialog/pages/CreateHouseholdsPage.tsx similarity index 83% rename from src/features/canvassAssignments/components/PlaceDialog/pages/CreateHouseholdsPage.tsx rename to src/features/canvass/components/LocationDialog/pages/CreateHouseholdsPage.tsx index ac4816d47b..5e49ba92dd 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/CreateHouseholdsPage.tsx +++ b/src/features/canvass/components/LocationDialog/pages/CreateHouseholdsPage.tsx @@ -4,27 +4,31 @@ import { FC, useCallback, useState } from 'react'; import { DoorFrontOutlined } from '@mui/icons-material'; import PageBase from './PageBase'; -import usePlaceMutations from 'features/canvassAssignments/hooks/usePlaceMutations'; import IntInput from '../IntInput'; +import useLocationMutations from 'features/canvass/hooks/useLocationMutations'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; type Props = { + locationId: string; onBack: () => void; onClose: () => void; orgId: number; - placeId: string; }; const CreateHouseholdsPage: FC = ({ onBack, onClose, orgId, - placeId, + locationId, }) => { + const messages = useMessages(messageIds); + const [numFloors, setNumFloors] = useState(2); const [numAptsPerFloor, setNumAptsPerFloor] = useState(3); const [creating, setCreating] = useState(false); const [scale, setScale] = useState(1); - const { addHouseholds } = usePlaceMutations(orgId, placeId); + const { addHouseholds } = useLocationMutations(orgId, locationId); const [container, setContainer] = useState(null); const updateSize = useCallback( @@ -70,7 +74,10 @@ const CreateHouseholdsPage: FC = ({ range(numFloors).flatMap((floorIndex) => range(numAptsPerFloor).map((aptIndex) => ({ floor: floorIndex + 1, - title: 'Household ' + (aptIndex + 1), + title: + messages.households.createMultiple.householdDefaultTitle({ + householdNumber: aptIndex + 1, + }), })) ) ); @@ -83,12 +90,15 @@ const CreateHouseholdsPage: FC = ({ } variant="contained" > - Create {numTotal} households + } onBack={onBack} onClose={onClose} - title="Create households" + title={messages.households.createMultiple.header()} > = ({ updateSize(value, numAptsPerFloor)} value={numFloors} /> updateSize(numFloors, value)} value={numAptsPerFloor} /> diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/EditHousehold.tsx b/src/features/canvass/components/LocationDialog/pages/EditHouseholdPage.tsx similarity index 70% rename from src/features/canvassAssignments/components/PlaceDialog/pages/EditHousehold.tsx rename to src/features/canvass/components/LocationDialog/pages/EditHouseholdPage.tsx index 5b8b00373a..ff9388a10f 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/EditHousehold.tsx +++ b/src/features/canvass/components/LocationDialog/pages/EditHouseholdPage.tsx @@ -2,7 +2,9 @@ import { Box, Button, TextField } from '@mui/material'; import { FC, useEffect, useState } from 'react'; import PageBase from './PageBase'; -import { Household } from 'features/canvassAssignments/types'; +import { Household } from 'features/areaAssignments/types'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; type Props = { household: Household; @@ -11,7 +13,14 @@ type Props = { onSave: (title: string, floor: number | null) => void; }; -const EditHousehold: FC = ({ onClose, onBack, onSave, household }) => { +const EditHouseholdPage: FC = ({ + onClose, + onBack, + onSave, + household, +}) => { + const messages = useMessages(messageIds); + const [title, setTitle] = useState(household.title || ''); const [floor, setFloor] = useState(household.floor ?? NaN); @@ -33,12 +42,14 @@ const EditHousehold: FC = ({ onClose, onBack, onSave, household }) => { }} variant="contained" > - Save + } onBack={onBack} onClose={onClose} - title={`Edit ${household.title || 'Untitled household'}`} + title={messages.households.edit.header({ + title: household.title, + })} > { @@ -49,13 +60,13 @@ const EditHousehold: FC = ({ onClose, onBack, onSave, household }) => { setTitle(ev.target.value)} value={title} /> setFloor(parseInt(ev.target.value))} type="number" value={floor} @@ -66,4 +77,4 @@ const EditHousehold: FC = ({ onClose, onBack, onSave, household }) => { ); }; -export default EditHousehold; +export default EditHouseholdPage; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/EditPlace.tsx b/src/features/canvass/components/LocationDialog/pages/EditLocationPage.tsx similarity index 53% rename from src/features/canvassAssignments/components/PlaceDialog/pages/EditPlace.tsx rename to src/features/canvass/components/LocationDialog/pages/EditLocationPage.tsx index cb5d894af3..af4ff49c31 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/EditPlace.tsx +++ b/src/features/canvass/components/LocationDialog/pages/EditLocationPage.tsx @@ -2,27 +2,37 @@ import { Box, Button, TextField } from '@mui/material'; import { FC, useEffect, useState } from 'react'; import PageBase from './PageBase'; -import { ZetkinPlace } from 'features/canvassAssignments/types'; +import { ZetkinLocation } from 'features/areaAssignments/types'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; -type EditPlaceProps = { +type EditLocationPageProps = { + location: ZetkinLocation; onBack: () => void; onClose: () => void; onSave: (title: string, description: string) => void; - place: ZetkinPlace; }; -const EditPlace: FC = ({ onClose, onBack, onSave, place }) => { - const [title, setTitle] = useState(place.title || ''); - const [description, setDescription] = useState(place.description || ''); +const EditLocationPage: FC = ({ + onClose, + onBack, + onSave, + location, +}) => { + const messages = useMessages(messageIds); + + const [title, setTitle] = useState(location.title || ''); + const [description, setDescription] = useState(location.description || ''); useEffect(() => { - setTitle(place.title || ''); - setDescription(place.description || ''); - }, [place]); + setTitle(location.title || ''); + setDescription(location.description || ''); + }, [location]); const nothingHasBeenEdited = - title == place.title && - (description == place.description || (!description && !place.description)); + title == location.title && + (description == location.description || + (!description && !location.description)); return ( = ({ onClose, onBack, onSave, place }) => { }} variant="contained" > - Save + } onBack={onBack} onClose={onClose} - title={`Edit ${place.title || 'Untitled place'}`} + title={messages.location.edit.header({ + title: location.title, + })} > { @@ -50,13 +62,13 @@ const EditPlace: FC = ({ onClose, onBack, onSave, place }) => { setTitle(ev.target.value)} value={title} /> setDescription(ev.target.value)} rows={5} @@ -68,4 +80,4 @@ const EditPlace: FC = ({ onClose, onBack, onSave, place }) => { ); }; -export default EditPlace; +export default EditLocationPage; diff --git a/src/features/canvass/components/LocationDialog/pages/HouseholdPage.tsx b/src/features/canvass/components/LocationDialog/pages/HouseholdPage.tsx new file mode 100644 index 0000000000..7e57af3903 --- /dev/null +++ b/src/features/canvass/components/LocationDialog/pages/HouseholdPage.tsx @@ -0,0 +1,56 @@ +import { Box, Button, Typography } from '@mui/material'; +import { FC } from 'react'; + +import { Household as ZetkinHousehold } from 'features/areaAssignments/types'; +import PageBase from './PageBase'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; + +type HouseholdPageProps = { + household: ZetkinHousehold; + onBack: () => void; + onClose: () => void; + onEdit: () => void; + onHouseholdVisitStart: () => void; + visitedInThisAssignment: boolean; +}; + +const HouseholdPage: FC = ({ + household, + onBack, + onClose, + onEdit, + onHouseholdVisitStart, + visitedInThisAssignment, +}) => { + const messages = useMessages(messageIds); + return ( + + {visitedInThisAssignment && ( + + + + )} + + + } + onBack={onBack} + onClose={onClose} + onEdit={onEdit} + subtitle={ + household.floor + ? messages.households.single.subtitle({ + floorNumber: household.floor, + }) + : messages.default.floor() + } + title={household.title} + /> + ); +}; + +export default HouseholdPage; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/VisitWizard.tsx b/src/features/canvass/components/LocationDialog/pages/HouseholdVisitPage.tsx similarity index 81% rename from src/features/canvassAssignments/components/PlaceDialog/pages/VisitWizard.tsx rename to src/features/canvass/components/LocationDialog/pages/HouseholdVisitPage.tsx index b4b2a1cc40..c921e54a58 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/VisitWizard.tsx +++ b/src/features/canvass/components/LocationDialog/pages/HouseholdVisitPage.tsx @@ -14,13 +14,15 @@ import { FC, useEffect, useState } from 'react'; import { Household, Visit, - ZetkinCanvassAssignment, -} from 'features/canvassAssignments/types'; + ZetkinAreaAssignment, +} from 'features/areaAssignments/types'; import PageBase from './PageBase'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; -type VisitWizardProps = { +type HouseholdVisitPageProps = { household: Household; - metrics: ZetkinCanvassAssignment['metrics']; + metrics: ZetkinAreaAssignment['metrics']; onBack: () => void; onLogVisit: ( responses: Visit['responses'], @@ -28,12 +30,14 @@ type VisitWizardProps = { ) => void; }; -const VisitWizard: FC = ({ +const HouseholdVisitPage: FC = ({ household, metrics, onBack, onLogVisit, }) => { + const messages = useMessages(messageIds); + const [responseByMetricId, setResponseByMetricId] = useState< Record >({}); @@ -60,20 +64,28 @@ const VisitWizard: FC = ({ }} variant="contained" > - Submit report + ) } onBack={onBack} - title={`${household.title || 'Unititled household'}: Log visit`} + title={messages.visit.household.header({ + householdTitle: household.title, + })} > {metrics.map((metric, index) => { const options = metric.kind == 'boolean' ? [ - { label: 'Yes', value: 'yes' }, - { label: 'No', value: 'no' }, + { + label: messages.visit.household.yesButtonLabel(), + value: 'yes', + }, + { + label: messages.visit.household.noButtonLabel(), + value: 'no', + }, ] : [ { label: 1, value: '1' }, @@ -148,7 +160,7 @@ const VisitWizard: FC = ({ {!metric.definesDone && ( )} @@ -161,4 +173,4 @@ const VisitWizard: FC = ({ ); }; -export default VisitWizard; +export default HouseholdVisitPage; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/HouseholdsPage.tsx b/src/features/canvass/components/LocationDialog/pages/HouseholdsPage.tsx similarity index 81% rename from src/features/canvassAssignments/components/PlaceDialog/pages/HouseholdsPage.tsx rename to src/features/canvass/components/LocationDialog/pages/HouseholdsPage.tsx index b372bb23ac..9835252769 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/HouseholdsPage.tsx +++ b/src/features/canvass/components/LocationDialog/pages/HouseholdsPage.tsx @@ -13,18 +13,20 @@ import { import { Add, Apps, KeyboardArrowRight } from '@mui/icons-material'; import PageBase from './PageBase'; -import { Household, ZetkinPlace } from 'features/canvassAssignments/types'; -import usePlaceMutations from 'features/canvassAssignments/hooks/usePlaceMutations'; +import { Household, ZetkinLocation } from 'features/areaAssignments/types'; import ZUIRelativeTime from 'zui/ZUIRelativeTime'; +import useLocationMutations from 'features/canvass/hooks/useLocationMutations'; +import messageIds from 'features/canvass/l10n/messageIds'; +import { Msg, useMessages } from 'core/i18n'; type Props = { + location: ZetkinLocation; onBack: () => void; onBulk: () => void; onClose: () => void; onCreateHousehold: (householdId: Household) => void; onSelectHousehold: (householdId: string) => void; orgId: number; - place: ZetkinPlace; }; const HouseholdsPage: FC = ({ @@ -34,12 +36,13 @@ const HouseholdsPage: FC = ({ onCreateHousehold, onSelectHousehold, orgId, - place, + location, }) => { + const messages = useMessages(messageIds); const [adding, setAdding] = useState(false); - const { addHousehold } = usePlaceMutations(orgId, place.id); + const { addHousehold } = useLocationMutations(orgId, location.id); - const sortedHouseholds = place.households.concat().sort((h0, h1) => { + const sortedHouseholds = location.households.concat().sort((h0, h1) => { const floor0 = h0.floor ?? Infinity; const floor1 = h1.floor ?? Infinity; @@ -50,13 +53,13 @@ const HouseholdsPage: FC = ({ - {place.households.length == 0 && ( + {location.households.length == 0 && ( - This place does not contain data about any households yet. + )} @@ -97,9 +100,7 @@ const HouseholdsPage: FC = ({ }} > - - {household.title || 'Untitled household'} - + {household.title} {mostRecentVisit && ( @@ -123,7 +124,9 @@ const HouseholdsPage: FC = ({ disabled={adding} onClick={async () => { setAdding(true); - const newlyAddedHousehold = await addHousehold(); + const newlyAddedHousehold = await addHousehold({ + title: messages.default.household(), + }); setAdding(false); onCreateHousehold(newlyAddedHousehold); }} diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/Place.tsx b/src/features/canvass/components/LocationDialog/pages/LocationPage.tsx similarity index 51% rename from src/features/canvassAssignments/components/PlaceDialog/pages/Place.tsx rename to src/features/canvass/components/LocationDialog/pages/LocationPage.tsx index e6ea53053e..c61b0e365d 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/Place.tsx +++ b/src/features/canvass/components/LocationDialog/pages/LocationPage.tsx @@ -9,91 +9,98 @@ import { import { FC } from 'react'; import { - ZetkinCanvassAssignment, - ZetkinPlace, -} from 'features/canvassAssignments/types'; + ZetkinAreaAssignment, + ZetkinLocation, +} from 'features/areaAssignments/types'; import PageBase from './PageBase'; -import usePlaceVisits from 'features/canvassAssignments/hooks/usePlaceVisits'; +import uselocationVisits from 'features/canvass/hooks/useLocationVisits'; import ZUIFuture from 'zui/ZUIFuture'; import ZUIRelativeTime from 'zui/ZUIRelativeTime'; -import estimateVisitedHouseholds from 'features/canvassAssignments/utils/estimateVisitedHouseholds'; +import estimateVisitedHouseholds from 'features/canvass/utils/estimateVisitedHouseholds'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; -type PlaceProps = { - assignment: ZetkinCanvassAssignment; +type LocationPageProps = { + assignment: ZetkinAreaAssignment; + location: ZetkinLocation; onClose: () => void; onEdit: () => void; onHouseholds: () => void; onVisit: () => void; - place: ZetkinPlace; }; -const Place: FC = ({ +const LocationPage: FC = ({ assignment, onClose, onEdit, onHouseholds, onVisit, - place, + location, }) => { - const visitsFuture = usePlaceVisits( + const messages = useMessages(messageIds); + const visitsFuture = uselocationVisits( assignment.organization.id, assignment.id, - place.id + location.id ); const numHouseholdsVisitedIndividually = - place?.households.filter((household) => - household.visits.some((visit) => visit.canvassAssId == assignment.id) + location?.households.filter((household) => + household.visits.some((visit) => visit.areaAssId == assignment.id) ).length ?? 0; - const numHouseholdsPerPlaceVisit = + const numHouseholdsPerLocationVisit = visitsFuture.data?.map(estimateVisitedHouseholds) ?? []; const numVisitedHouseholds = Math.max( numHouseholdsVisitedIndividually, - ...numHouseholdsPerPlaceVisit + ...numHouseholdsPerLocationVisit ); - const numHouseholds = Math.max(place.households.length, numVisitedHouseholds); + const numHouseholds = Math.max( + location.households.length, + numVisitedHouseholds + ); return ( - + - {place.description || 'Empty description'} + {location.description || messages.default.description()} {!!numHouseholds && ( <> - {`${numVisitedHouseholds} of ${numHouseholds}`} + + + + - households visited )} {!numHouseholds && ( - No households registered here yet + )} - {assignment.reporting_level == 'place' && ( + {assignment.reporting_level == 'location' && ( )} @@ -103,7 +110,9 @@ const Place: FC = ({ {(visits) => ( <> - History + + + {visits.map((visit) => { const households = estimateVisitedHouseholds(visit); @@ -115,7 +124,12 @@ const Place: FC = ({ justifyContent="space-between" width="100%" > - {households} households + + + @@ -130,4 +144,4 @@ const Place: FC = ({ ); }; -export default Place; +export default LocationPage; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/PlaceVisitPage.tsx b/src/features/canvass/components/LocationDialog/pages/LocationVisitPage.tsx similarity index 87% rename from src/features/canvassAssignments/components/PlaceDialog/pages/PlaceVisitPage.tsx rename to src/features/canvass/components/LocationDialog/pages/LocationVisitPage.tsx index 9229c8cdcf..7f33ace251 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/PlaceVisitPage.tsx +++ b/src/features/canvass/components/LocationDialog/pages/LocationVisitPage.tsx @@ -12,26 +12,27 @@ import { import PageBase from './PageBase'; import IntInput from '../IntInput'; -import { - ZetkinCanvassAssignment, - ZetkinPlaceVisit, -} from 'features/canvassAssignments/types'; +import { ZetkinAreaAssignment } from 'features/areaAssignments/types'; +import { ZetkinLocationVisit } from 'features/canvass/types'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/canvass/l10n/messageIds'; type Props = { active: boolean; - assignment: ZetkinCanvassAssignment; + assignment: ZetkinAreaAssignment; onBack: () => void; onClose: () => void; - onLogVisit: (responses: ZetkinPlaceVisit['responses']) => Promise; + onLogVisit: (responses: ZetkinLocationVisit['responses']) => Promise; }; -const PlaceVisitPage: FC = ({ +const LocationVisitPage: FC = ({ active, assignment, onBack, onClose, onLogVisit, }) => { + const messages = useMessages(messageIds); const [submitting, setSubmitting] = useState(false); const [step, setStep] = useState(0); const [numHouseholds, setNumHouseholds] = useState(0); @@ -85,13 +86,13 @@ const PlaceVisitPage: FC = ({ } variant="contained" > - Submit + ) } onBack={onBack} onClose={onClose} - title="Report visits here" + title={messages.visit.location.header()} > = ({ {completed && step != index && ( - ({numHouseholds} households, {numYes} yes) + )} @@ -161,7 +165,7 @@ const PlaceVisitPage: FC = ({ width="70%" > { setValuesByMetricId((current) => ({ @@ -172,7 +176,7 @@ const PlaceVisitPage: FC = ({ value={values[0]} /> { setValuesByMetricId((current) => ({ @@ -188,7 +192,9 @@ const PlaceVisitPage: FC = ({ onClick={() => setStep((current) => current + 1)} variant="contained" > - Proceed + )} @@ -230,7 +236,10 @@ const PlaceVisitPage: FC = ({ {completed && step != index && ( - ({numHouseholds} households, {avgFixed} average) + )} @@ -288,4 +297,4 @@ const PlaceVisitPage: FC = ({ ); }; -export default PlaceVisitPage; +export default LocationVisitPage; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/PageBase.tsx b/src/features/canvass/components/LocationDialog/pages/PageBase.tsx similarity index 100% rename from src/features/canvassAssignments/components/PlaceDialog/pages/PageBase.tsx rename to src/features/canvass/components/LocationDialog/pages/PageBase.tsx diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/PageBaseHeader.tsx b/src/features/canvass/components/LocationDialog/pages/PageBaseHeader.tsx similarity index 100% rename from src/features/canvassAssignments/components/PlaceDialog/pages/PageBaseHeader.tsx rename to src/features/canvass/components/LocationDialog/pages/PageBaseHeader.tsx diff --git a/src/features/canvassAssignments/hooks/useAllPlaceVisits.ts b/src/features/canvass/hooks/useAllLocationVisits.ts similarity index 70% rename from src/features/canvassAssignments/hooks/useAllPlaceVisits.ts rename to src/features/canvass/hooks/useAllLocationVisits.ts index 5aa16b086f..6424e7bc72 100644 --- a/src/features/canvassAssignments/hooks/useAllPlaceVisits.ts +++ b/src/features/canvass/hooks/useAllLocationVisits.ts @@ -2,11 +2,14 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { visitsLoad, visitsLoaded } from '../store'; -export default function useAllPlaceVisits(orgId: number, assignmentId: string) { +export default function useAllLocationVisits( + orgId: number, + assignmentId: string +) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const visitList = useAppSelector( - (state) => state.canvassAssignments.visitsByAssignmentId[assignmentId] + (state) => state.canvass.visitsByAssignmentId[assignmentId] ); return loadListIfNecessary(visitList, dispatch, { @@ -14,7 +17,7 @@ export default function useAllPlaceVisits(orgId: number, assignmentId: string) { actionOnSuccess: (items) => visitsLoaded([assignmentId, items]), loader: () => apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${assignmentId}/visits` + `/beta/orgs/${orgId}/areaassignments/${assignmentId}/visits` ), }); } diff --git a/src/features/canvass/hooks/useCreateLocation.ts b/src/features/canvass/hooks/useCreateLocation.ts new file mode 100644 index 0000000000..e170abd68d --- /dev/null +++ b/src/features/canvass/hooks/useCreateLocation.ts @@ -0,0 +1,17 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinLocation } from '../../areaAssignments/types'; +import { locationCreated } from '../../areaAssignments/store'; +import { ZetkinLocationPostBody } from '../types'; + +export default function useCreateLocation(orgId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async function createLocation(data: ZetkinLocationPostBody) { + const created = await apiClient.post< + ZetkinLocation, + ZetkinLocationPostBody + >(`/beta/orgs/${orgId}/locations`, data); + dispatch(locationCreated(created)); + }; +} diff --git a/src/features/canvass/hooks/useLocationMutations.ts b/src/features/canvass/hooks/useLocationMutations.ts new file mode 100644 index 0000000000..4422eed41e --- /dev/null +++ b/src/features/canvass/hooks/useLocationMutations.ts @@ -0,0 +1,84 @@ +import { useState } from 'react'; + +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + HouseholdPatchBody, + ZetkinLocationPatchBody, + ZetkinLocationVisit, + ZetkinLocationVisitPostBody, +} from '../types'; +import { visitCreated } from '../store'; +import { locationUpdated } from '../../areaAssignments/store'; +import createHouseholds from '../rpc/createHouseholds/client'; +import { Visit, ZetkinLocation } from 'features/areaAssignments/types'; + +export default function useLocationMutations( + orgId: number, + locationId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const [isAddVisitLoading, setIsAddVisitLoading] = useState(false); + + return { + addHousehold: async (data: Partial) => { + const location = await apiClient.post( + `/beta/orgs/${orgId}/locations/${locationId}/households`, + data + ); + dispatch(locationUpdated(location)); + return location.households[0]; + }, + addHouseholds: async (households: { floor: number; title: string }[]) => { + const location = await apiClient.rpc(createHouseholds, { + households, + locationId, + orgId, + }); + dispatch(locationUpdated(location)); + }, + addVisit: async ( + householdId: string, + data: Omit + ) => { + setIsAddVisitLoading(true); + const location = await apiClient.post< + ZetkinLocation, + Omit + >( + `/beta/orgs/${orgId}/locations/${locationId}/households/${householdId}/visits`, + data + ); + dispatch(locationUpdated(location)); + setIsAddVisitLoading(false); + }, + isAddVisitLoading, + reportLocationVisit: async ( + areaAssId: string, + data: ZetkinLocationVisitPostBody + ) => { + const visit = await apiClient.post< + ZetkinLocationVisit, + ZetkinLocationVisitPostBody + >(`/beta/orgs/${orgId}/areaassignments/${areaAssId}/visits`, data); + dispatch(visitCreated(visit)); + }, + updateHousehold: async (householdId: string, data: HouseholdPatchBody) => { + const location = await apiClient.patch< + ZetkinLocation, + HouseholdPatchBody + >( + `/beta/orgs/${orgId}/locations/${locationId}/households/${householdId}`, + data + ); + dispatch(locationUpdated(location)); + }, + updateLocation: async (data: ZetkinLocationPatchBody) => { + const location = await apiClient.patch< + ZetkinLocation, + ZetkinLocationPatchBody + >(`/beta/orgs/${orgId}/locations/${locationId}`, data); + dispatch(locationUpdated(location)); + }, + }; +} diff --git a/src/features/canvassAssignments/hooks/usePlaceVisits.ts b/src/features/canvass/hooks/useLocationVisits.ts similarity index 73% rename from src/features/canvassAssignments/hooks/usePlaceVisits.ts rename to src/features/canvass/hooks/useLocationVisits.ts index 72c221ebec..5514db844f 100644 --- a/src/features/canvassAssignments/hooks/usePlaceVisits.ts +++ b/src/features/canvass/hooks/useLocationVisits.ts @@ -3,15 +3,15 @@ import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { visitsLoad, visitsLoaded } from '../store'; import { ResolvedFuture } from 'core/caching/futures'; -export default function usePlaceVisits( +export default function useLocationVisits( orgId: number, assignmentId: string, - placeId: string + locationId: string ) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const visitList = useAppSelector( - (state) => state.canvassAssignments.visitsByAssignmentId[assignmentId] + (state) => state.canvass.visitsByAssignmentId[assignmentId] ); const future = loadListIfNecessary(visitList, dispatch, { @@ -19,13 +19,13 @@ export default function usePlaceVisits( actionOnSuccess: (items) => visitsLoaded([assignmentId, items]), loader: () => apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${assignmentId}/visits` + `/beta/orgs/${orgId}/areaassignments/${assignmentId}/visits` ), }); if (future.data) { return new ResolvedFuture( - future.data.filter((visit) => visit.placeId == placeId) + future.data.filter((visit) => visit.locationId == locationId) ); } diff --git a/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts b/src/features/canvass/hooks/useMyAreaAssignments.ts similarity index 78% rename from src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts rename to src/features/canvass/hooks/useMyAreaAssignments.ts index a046ed90cd..63e6a58531 100644 --- a/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts +++ b/src/features/canvass/hooks/useMyAreaAssignments.ts @@ -3,17 +3,17 @@ import { myAssignmentsLoad, myAssignmentsLoaded } from '../store'; import { AssignmentWithAreas } from '../types'; import useRemoteList from 'core/hooks/useRemoteList'; -export default function useMyCanvassAssignments() { +export default function useMyAreaAssignments() { const apiClient = useApiClient(); const list = useAppSelector( - (state) => state.canvassAssignments.myAssignmentsWithAreasList + (state) => state.canvass.myAssignmentsWithAreasList ); const assignments = useRemoteList(list, { actionOnLoad: () => myAssignmentsLoad(), actionOnSuccess: (data) => myAssignmentsLoaded(data), loader: () => - apiClient.get('/beta/users/me/canvassassignments'), + apiClient.get('/beta/users/me/areaassignments'), }); const now = new Date(); diff --git a/src/features/canvassAssignments/hooks/useSidebarStats.ts b/src/features/canvass/hooks/useSidebarStats.ts similarity index 62% rename from src/features/canvassAssignments/hooks/useSidebarStats.ts rename to src/features/canvass/hooks/useSidebarStats.ts index 1665ae348b..a8f60074fa 100644 --- a/src/features/canvassAssignments/hooks/useSidebarStats.ts +++ b/src/features/canvass/hooks/useSidebarStats.ts @@ -1,13 +1,11 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { - placesInvalidated, - placesLoad, - placesLoaded, - visitsInvalidated, - visitsLoad, - visitsLoaded, -} from '../store'; + locationsInvalidated, + locationsLoad, + locationsLoaded, +} from '../../areaAssignments/store'; +import { visitsLoad, visitsLoaded, visitsInvalidated } from '../store'; import useMembership from 'features/organizations/hooks/useMembership'; import estimateVisitedHouseholds from '../utils/estimateVisitedHouseholds'; @@ -16,13 +14,13 @@ type UseSidebarReturn = { stats: { allTime: { numHouseholds: number; - numPlaces: number; + numLocations: number; }; today: { numHouseholds: number; - numPlaces: number; + numLocations: number; numUserHouseholds: number; - numUserPlaces: number; + numUserLocations: number; }; }; sync: () => void; @@ -35,20 +33,20 @@ export default function useSidebarStats( ): UseSidebarReturn { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const placeList = useAppSelector( - (state) => state.canvassAssignments.placeList + const locationList = useAppSelector( + (state) => state.areaAssignments.locationList ); const visitList = useAppSelector( - (state) => state.canvassAssignments.visitsByAssignmentId[assignmentId] + (state) => state.canvass.visitsByAssignmentId[assignmentId] ); const membershipFuture = useMembership(orgId); const userPersonId = membershipFuture.data?.profile.id; - const placeListFuture = loadListIfNecessary(placeList, dispatch, { - actionOnLoad: () => placesLoad(), - actionOnSuccess: (items) => placesLoaded(items), - loader: () => apiClient.get(`/beta/orgs/${orgId}/places`), + const locationListFuture = loadListIfNecessary(locationList, dispatch, { + actionOnLoad: () => locationsLoad(), + actionOnSuccess: (items) => locationsLoaded(items), + loader: () => apiClient.get(`/beta/orgs/${orgId}/locations`), }); const visitListFuture = loadListIfNecessary(visitList, dispatch, { @@ -56,26 +54,26 @@ export default function useSidebarStats( actionOnSuccess: (items) => visitsLoaded([assignmentId, items]), loader: () => apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${assignmentId}/visits` + `/beta/orgs/${orgId}/areaassignments/${assignmentId}/visits` ), }); const stats = { allTime: { numHouseholds: 0, - numPlaces: 0, + numLocations: 0, }, today: { numHouseholds: 0, - numPlaces: 0, + numLocations: 0, numUserHouseholds: 0, - numUserPlaces: 0, + numUserLocations: 0, }, }; - const userPlacesToday = new Set(); - const teamPlacesToday = new Set(); - const teamPlaces = new Set(); + const userLocationsToday = new Set(); + const teamLocationsToday = new Set(); + const teamLocations = new Set(); const userHouseholdsToday = new Set(); const teamHouseholdsToday = new Set(); @@ -83,11 +81,11 @@ export default function useSidebarStats( const todayStr = new Date().toISOString().slice(0, 10); - if (placeListFuture.data) { - placeListFuture.data.forEach((place) => { - place.households.forEach((household) => { + if (locationListFuture.data) { + locationListFuture.data.forEach((location) => { + location.households.forEach((household) => { household.visits.forEach((visit) => { - if (visit.canvassAssId == assignmentId) { + if (visit.areaAssId == assignmentId) { teamHouseholds.add(household.id); if (visit.timestamp.startsWith(todayStr)) { @@ -111,31 +109,31 @@ export default function useSidebarStats( visitListFuture.data.forEach((visit) => { const numHouseholds = estimateVisitedHouseholds(visit); - teamPlaces.add(visit.placeId); + teamLocations.add(visit.locationId); stats.allTime.numHouseholds += numHouseholds; if (visit.timestamp.startsWith(todayStr)) { - teamPlacesToday.add(visit.placeId); + teamLocationsToday.add(visit.locationId); stats.today.numHouseholds += numHouseholds; if (visit.personId == userPersonId) { - userPlacesToday.add(visit.placeId); + userLocationsToday.add(visit.locationId); stats.today.numUserHouseholds += numHouseholds; } } }); } - stats.allTime.numPlaces = teamPlaces.size; - stats.today.numPlaces = teamPlacesToday.size; - stats.today.numUserPlaces = userPlacesToday.size; + stats.allTime.numLocations = teamLocations.size; + stats.today.numLocations = teamLocationsToday.size; + stats.today.numUserLocations = userLocationsToday.size; return { - loading: placeListFuture.isLoading || visitListFuture.isLoading, + loading: locationListFuture.isLoading || visitListFuture.isLoading, stats, sync: () => { dispatch(visitsInvalidated(assignmentId)); - dispatch(placesInvalidated()); + dispatch(locationsInvalidated()); }, synced: visitList?.loaded || null, }; diff --git a/src/features/canvass/l10n/messageIds.ts b/src/features/canvass/l10n/messageIds.ts new file mode 100644 index 0000000000..1fc31d128e --- /dev/null +++ b/src/features/canvass/l10n/messageIds.ts @@ -0,0 +1,137 @@ +import { ReactElement } from 'react'; + +import { m, makeMessages } from 'core/i18n'; + +export default makeMessages('feat.canvass', { + default: { + description: m('Empty description'), + floor: m('Unknown floor'), + household: m('Untitled household'), + location: m('Untitled location'), + }, + households: { + createMultiple: { + createButtonLabel: m<{ numHouseholds: number }>( + 'Create {numHouseholds, plural, one {1 household} other {# households}}' + ), + header: m('Create households'), + householdDefaultTitle: m<{ householdNumber: number }>( + 'Household {householdNumber}' + ), + numberOfFloorsInput: m('Number of floors'), + numberOfHouseholdsInput: m('Households per floor'), + }, + edit: { + floorLabel: m('Edit floor'), + header: m<{ title: string }>('Edit {title}'), + saveButtonLabel: m('Save'), + titleLabel: m('Edit title'), + }, + page: { + empty: m('This location does not have any households yet'), + header: m('Households'), + }, + single: { + logVisitButtonLabel: m('Log visit'), + subtitle: m<{ floorNumber: number }>('Floor {floorNumber}'), + wasVisited: m('This household has been visited in this assignment'), + }, + }, + instructions: { + areas: m('Areas'), + instructionsHeader: m('Instructions'), + ready: m('You are ready to go'), + start: m('Start assignment'), + visitedHouseholds: m('Households visited'), + visitedLocations: m('Locations visited'), + }, + location: { + edit: { + descriptionLabel: m('Edit description'), + header: m<{ title: string }>('Edit {title}'), + saveButtonLabel: m('Save'), + titleLabel: m('Edit title'), + }, + header: m<{ numHouseholds: number; numVisitedHouseholds: number }>( + '{numVisitedHouseholds}/{numHouseholds} {numHouseholds, plural, one {household} other {households}} visited' + ), + page: { + historySectionHeader: m('History'), + householdVisitedStatLabel: m('households visited'), + householdsButtonLabel: m('Households'), + householdsVisitedStat: m<{ + numHouseholds: number; + numVisitedHouseholds: number; + }>('{numVisitedHouseholds} of {numHouseholds}'), + noHouseholds: m('No households registered here yet'), + numberOfHouseholds: m<{ numHouseholds: number }>( + '{numHouseholds, plural, one {1 household} other {# households}}' + ), + quickReportButtonLabel: m('Quick report'), + }, + }, + map: { + addLocation: { + add: m('Add location'), + cancel: m('Cancel'), + create: m('Create location'), + inputPlaceholder: m('Give the location a name'), + }, + }, + sidebar: { + instructions: { + title: m('Instructions'), + }, + menuOptions: { + home: m('My activities'), + logOut: m('Log out'), + }, + progress: { + allTime: { + title: m('All time'), + }, + header: { + households: m('Households'), + locations: m('Locations'), + title: m('Progress'), + }, + session: { + team: m('Team'), + title: m('Session (today)'), + you: m('You'), + }, + sync: { + label: { + hasLoaded: m<{ timestamp: ReactElement }>('Synced {timestamp}'), + neverLoaded: m('Never loaded'), + }, + syncButton: { + label: m('Sync now'), + loading: m('Syncing'), + }, + }, + }, + }, + visit: { + household: { + header: m<{ householdTitle: string }>('{householdTitle}: Log visit'), + noButtonLabel: m('No'), + skipButtonLabel: m('Skip this question'), + submitReportButtonLabel: m('Submit report'), + yesButtonLabel: m('Yes'), + }, + location: { + average: m<{ avgFixed: string; numHouseholds: number }>( + '{numHouseholds, plural,one {1 household} other {# households}}, {avgFixed} average' + ), + completed: m<{ numHouseholds: number; numYes: number }>( + '{numHouseholds, plural,one {1 household} other {# households}}, {numYes} yes' + ), + header: m('Report visits here'), + noInputLabel: m('No'), + proceedButtonLabel: m('Proceed'), + submitButtonLabel: m('Submit'), + yesInputLabel: m('Yes'), + }, + }, +}); diff --git a/src/features/canvassAssignments/rpc/createHouseholds/client.ts b/src/features/canvass/rpc/createHouseholds/client.ts similarity index 76% rename from src/features/canvassAssignments/rpc/createHouseholds/client.ts rename to src/features/canvass/rpc/createHouseholds/client.ts index b99240b999..9e8e1b2348 100644 --- a/src/features/canvassAssignments/rpc/createHouseholds/client.ts +++ b/src/features/canvass/rpc/createHouseholds/client.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { makeRPCDef } from 'core/rpc/types'; -import { ZetkinPlace } from '../../types'; +import { ZetkinLocation } from 'features/areaAssignments/types'; export const paramsSchema = z.object({ households: z.array( @@ -10,11 +10,11 @@ export const paramsSchema = z.object({ title: z.string().optional(), }) ), + locationId: z.string(), orgId: z.number(), - placeId: z.string(), }); type Params = z.input; -type Result = ZetkinPlace; +type Result = ZetkinLocation; export default makeRPCDef('createHouseholds'); diff --git a/src/features/canvassAssignments/rpc/createHouseholds/server.ts b/src/features/canvass/rpc/createHouseholds/server.ts similarity index 73% rename from src/features/canvassAssignments/rpc/createHouseholds/server.ts rename to src/features/canvass/rpc/createHouseholds/server.ts index c5c48a073f..c8656312b7 100644 --- a/src/features/canvassAssignments/rpc/createHouseholds/server.ts +++ b/src/features/canvass/rpc/createHouseholds/server.ts @@ -2,11 +2,11 @@ import mongoose from 'mongoose'; import { z } from 'zod'; import { paramsSchema } from './client'; -import { ZetkinPlace } from 'features/canvassAssignments/types'; -import { PlaceModel } from 'features/canvassAssignments/models'; +import { ZetkinLocation } from 'features/areaAssignments/types'; +import { LocationModel } from 'features/areaAssignments/models'; type Params = z.input; -type Result = ZetkinPlace; +type Result = ZetkinLocation; export const createHouseholdsDef = { handler: handle, @@ -17,10 +17,10 @@ export const createHouseholdsDef = { async function handle(params: Params): Promise { await mongoose.connect(process.env.MONGODB_URL || ''); - const { households, orgId, placeId } = params; + const { households, orgId, locationId } = params; - const model = await PlaceModel.findOneAndUpdate( - { _id: placeId, orgId }, + const model = await LocationModel.findOneAndUpdate( + { _id: locationId, orgId }, { $push: { households: { @@ -39,7 +39,7 @@ async function handle(params: Params): Promise { ); if (!model) { - throw new Error('Unknown place'); + throw new Error('Unknown location'); } return { diff --git a/src/features/canvass/store.ts b/src/features/canvass/store.ts new file mode 100644 index 0000000000..32aa72156e --- /dev/null +++ b/src/features/canvass/store.ts @@ -0,0 +1,75 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { remoteItem, RemoteList, remoteList } from 'utils/storeUtils'; +import { AssignmentWithAreas, ZetkinLocationVisit } from './types'; + +export interface CanvassStoreSlice { + myAssignmentsWithAreasList: RemoteList; + visitsByAssignmentId: Record>; +} + +const initialState: CanvassStoreSlice = { + myAssignmentsWithAreasList: remoteList(), + visitsByAssignmentId: {}, +}; + +const canvassSlice = createSlice({ + initialState: initialState, + name: 'canvass', + reducers: { + myAssignmentsLoad: (state) => { + state.myAssignmentsWithAreasList.isLoading = true; + }, + myAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + const assignments = action.payload; + const timestamp = new Date().toISOString(); + + state.myAssignmentsWithAreasList = remoteList(assignments); + state.myAssignmentsWithAreasList.loaded = timestamp; + state.myAssignmentsWithAreasList.items.forEach( + (item) => (item.loaded = timestamp) + ); + }, + visitCreated: (state, action: PayloadAction) => { + const visit = action.payload; + const assignmentId = visit.areaAssId; + if (!state.visitsByAssignmentId[assignmentId]) { + state.visitsByAssignmentId[assignmentId] = remoteList(); + } + + state.visitsByAssignmentId[assignmentId].items.push( + remoteItem(visit.id, { data: visit }) + ); + }, + visitsInvalidated: (state, action: PayloadAction) => { + const assignmentId = action.payload; + state.visitsByAssignmentId[assignmentId].isStale = true; + }, + visitsLoad: (state, action: PayloadAction) => { + state.visitsByAssignmentId[action.payload] = remoteList(); + state.visitsByAssignmentId[action.payload].isLoading = true; + }, + visitsLoaded: ( + state, + action: PayloadAction<[string, ZetkinLocationVisit[]]> + ) => { + const [locationId, visits] = action.payload; + state.visitsByAssignmentId[locationId] = remoteList(visits); + state.visitsByAssignmentId[locationId].isLoading = false; + state.visitsByAssignmentId[locationId].loaded = new Date().toISOString(); + }, + }, +}); + +export default canvassSlice; +export const { + myAssignmentsLoad, + myAssignmentsLoaded, + visitCreated, + visitsInvalidated, + visitsLoad, + visitsLoaded, +} = canvassSlice.actions; diff --git a/src/features/canvass/types.ts b/src/features/canvass/types.ts new file mode 100644 index 0000000000..14edff256a --- /dev/null +++ b/src/features/canvass/types.ts @@ -0,0 +1,41 @@ +import { + Household, + Visit, + ZetkinAreaAssignment, + ZetkinLocation, +} from 'features/areaAssignments/types'; +import { ZetkinArea } from 'features/areas/types'; + +export type AssignmentWithAreas = ZetkinAreaAssignment & { + areas: ZetkinArea[]; +}; + +export type ZetkinLocationVisit = { + areaAssId: string; + id: string; + locationId: string; + personId: number; + responses: { + metricId: string; + responseCounts: number[]; + }[]; + timestamp: string; +}; + +export type ZetkinLocationVisitPostBody = Omit< + ZetkinLocationVisit, + 'id' | 'timestamp' | 'personId' +>; + +export type ZetkinLocationPostBody = Partial< + Omit +>; + +export type ZetkinLocationPatchBody = Partial< + Omit +> & { + households?: Partial> & + { visits?: Partial>[] }[]; +}; + +export type HouseholdPatchBody = Partial>; diff --git a/src/features/canvassAssignments/utils/asCanvasserAuthorized.ts b/src/features/canvass/utils/asAreaAssigneeAuthorized.ts similarity index 86% rename from src/features/canvassAssignments/utils/asCanvasserAuthorized.ts rename to src/features/canvass/utils/asAreaAssigneeAuthorized.ts index a21755783d..467c1cad61 100644 --- a/src/features/canvassAssignments/utils/asCanvasserAuthorized.ts +++ b/src/features/canvass/utils/asAreaAssigneeAuthorized.ts @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server'; import BackendApiClient from 'core/api/client/BackendApiClient'; import { ZetkinMembership } from 'utils/types/zetkin'; import { ApiClientError } from 'core/api/errors'; -import { CanvassAssignmentModel } from '../models'; +import { AreaAssignmentModel } from '../../areaAssignments/models'; type GuardedFnProps = { apiClient: BackendApiClient; @@ -21,7 +21,7 @@ type AuthParams = { request: Request; }; -export default async function asCanvasserAuthorized( +export default async function asAreaAssigneeAuthorized( params: AuthParams, fn: GuardedFn ): Promise { @@ -37,14 +37,14 @@ export default async function asCanvasserAuthorized( if (!membership.role) { await mongoose.connect(process.env.MONGODB_URL || ''); - const assignmentModels = await CanvassAssignmentModel.find({ + const assignmentModels = await AreaAssignmentModel.find({ orgId: membership.organization.id, 'sessions.personId': { $eq: membership.profile.id }, }); if (!assignmentModels.length) { return NextResponse.json( - { error: { title: 'Must be canvasser' } }, + { error: { title: 'Must be areaAssignee' } }, { status: 403 } ); } diff --git a/src/features/canvassAssignments/utils/estimateVisitedHouseholds.ts b/src/features/canvass/utils/estimateVisitedHouseholds.ts similarity index 76% rename from src/features/canvassAssignments/utils/estimateVisitedHouseholds.ts rename to src/features/canvass/utils/estimateVisitedHouseholds.ts index 01dec098f0..f5d5da9542 100644 --- a/src/features/canvassAssignments/utils/estimateVisitedHouseholds.ts +++ b/src/features/canvass/utils/estimateVisitedHouseholds.ts @@ -1,7 +1,7 @@ -import { ZetkinPlaceVisit } from '../types'; +import { ZetkinLocationVisit } from '../types'; export default function estimateVisitedHouseholds( - visit: ZetkinPlaceVisit + visit: ZetkinLocationVisit ): number { const householdsPerMetric = visit.responses.map((response) => response.responseCounts.reduce((sum, value) => sum + value) diff --git a/src/features/canvassAssignments/utils/getCrosshairPositionOnMap.tsx b/src/features/canvass/utils/getCrosshairPositionOnMap.tsx similarity index 100% rename from src/features/canvassAssignments/utils/getCrosshairPositionOnMap.tsx rename to src/features/canvass/utils/getCrosshairPositionOnMap.tsx diff --git a/src/features/canvassAssignments/utils/getVisitState.spec.ts b/src/features/canvass/utils/getVisitState.spec.ts similarity index 94% rename from src/features/canvassAssignments/utils/getVisitState.spec.ts rename to src/features/canvass/utils/getVisitState.spec.ts index 3c5243e1ab..4320ef7519 100644 --- a/src/features/canvassAssignments/utils/getVisitState.spec.ts +++ b/src/features/canvass/utils/getVisitState.spec.ts @@ -16,7 +16,7 @@ describe('getVisitState()', () => { title: 'Door 2', visits: [ { - canvassAssId: '345', + areaAssId: '345', id: 'a', noteToOfficial: '', personId: 1, @@ -41,7 +41,7 @@ describe('getVisitState()', () => { title: 'Door 2', visits: [ { - canvassAssId: '123', + areaAssId: '123', id: 'a', noteToOfficial: '', personId: 1, @@ -65,7 +65,7 @@ describe('getVisitState()', () => { title: 'Door 2', visits: [ { - canvassAssId: '123', + areaAssId: '123', id: 'a', noteToOfficial: '', personId: 1, diff --git a/src/features/canvassAssignments/utils/getVisitState.ts b/src/features/canvass/utils/getVisitState.ts similarity index 84% rename from src/features/canvassAssignments/utils/getVisitState.ts rename to src/features/canvass/utils/getVisitState.ts index 2356261de4..8706a21974 100644 --- a/src/features/canvassAssignments/utils/getVisitState.ts +++ b/src/features/canvass/utils/getVisitState.ts @@ -1,15 +1,15 @@ -import { Household } from '../types'; +import { Household } from '../../areaAssignments/types'; export type ProgressState = 'none' | 'some' | 'all'; export default function getVisitState( households: Household[], - canvassAssId: string | null + areaAssId: string | null ): ProgressState { let numberOfVisitedHouseholds = 0; households.forEach((household) => { const hasVisitsInCurrentAssignment = household.visits.some((visit) => { - return visit.canvassAssId == canvassAssId; + return visit.areaAssId == areaAssId; }); if (hasVisitsInCurrentAssignment) { diff --git a/src/features/canvassAssignments/utils/isPointInsidePolygon.ts b/src/features/canvass/utils/isPointInsidePolygon.ts similarity index 100% rename from src/features/canvassAssignments/utils/isPointInsidePolygon.ts rename to src/features/canvass/utils/isPointInsidePolygon.ts diff --git a/src/features/canvassAssignments/utils/markerIcon.tsx b/src/features/canvass/utils/markerIcon.tsx similarity index 100% rename from src/features/canvassAssignments/utils/markerIcon.tsx rename to src/features/canvass/utils/markerIcon.tsx diff --git a/src/features/canvassAssignments/components/PlaceDialog/ContractedHeader.tsx b/src/features/canvassAssignments/components/PlaceDialog/ContractedHeader.tsx deleted file mode 100644 index e1b5f3122f..0000000000 --- a/src/features/canvassAssignments/components/PlaceDialog/ContractedHeader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { FC } from 'react'; -import { IconButton } from '@mui/material'; -import { KeyboardArrowUp } from '@mui/icons-material'; - -import PageBaseHeader from './pages/PageBaseHeader'; -import usePlaceVisits from 'features/canvassAssignments/hooks/usePlaceVisits'; -import { - ZetkinCanvassAssignment, - ZetkinPlace, -} from 'features/canvassAssignments/types'; -import estimateVisitedHouseholds from 'features/canvassAssignments/utils/estimateVisitedHouseholds'; - -type Props = { - assignment: ZetkinCanvassAssignment; - place: ZetkinPlace; -}; - -const ContractedHeader: FC = ({ assignment, place }) => { - const visitsFuture = usePlaceVisits( - assignment.organization.id, - assignment.id, - place.id - ); - - const numHouseholdsVisitedIndividually = - place?.households.filter((household) => - household.visits.some((visit) => visit.canvassAssId == assignment.id) - ).length ?? 0; - - const numHouseholdsPerPlaceVisit = - visitsFuture.data?.map(estimateVisitedHouseholds) ?? []; - - const numVisitedHouseholds = Math.max( - numHouseholdsVisitedIndividually, - ...numHouseholdsPerPlaceVisit - ); - - const numHouseholds = Math.max(place.households.length, numVisitedHouseholds); - - return ( - - - - } - subtitle={`${numVisitedHouseholds} / ${numHouseholds} households visited`} - title={place.title || 'Untitled place'} - /> - ); -}; - -export default ContractedHeader; diff --git a/src/features/canvassAssignments/components/PlaceDialog/pages/Household.tsx b/src/features/canvassAssignments/components/PlaceDialog/pages/Household.tsx deleted file mode 100644 index 6e15bf51d7..0000000000 --- a/src/features/canvassAssignments/components/PlaceDialog/pages/Household.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Box, Button } from '@mui/material'; -import { FC } from 'react'; - -import { Household as ZetkinHousehold } from 'features/canvassAssignments/types'; -import PageBase from './PageBase'; - -type HouseholdProps = { - household: ZetkinHousehold; - onBack: () => void; - onClose: () => void; - onEdit: () => void; - onWizardStart: () => void; - visitedInThisAssignment: boolean; -}; - -const Household: FC = ({ - household, - onBack, - onClose, - onEdit, - onWizardStart, - visitedInThisAssignment, -}) => { - return ( - - {visitedInThisAssignment && - 'This household has been visted in this assignment.'} - - - } - onBack={onBack} - onClose={onClose} - onEdit={onEdit} - subtitle={household.floor ? `Floor ${household.floor}` : 'Unknown floor'} - title={household.title || 'Untitled household'} - /> - ); -}; - -export default Household; diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignment.ts b/src/features/canvassAssignments/hooks/useCanvassAssignment.ts deleted file mode 100644 index ff8052193e..0000000000 --- a/src/features/canvassAssignments/hooks/useCanvassAssignment.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { loadItemIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { ZetkinCanvassAssignment } from '../types'; -import { canvassAssignmentLoad, canvassAssignmentLoaded } from '../store'; - -export default function useCanvassAssignment( - orgId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const canvassAssignmenList = useAppSelector( - (state) => state.canvassAssignments.canvassAssignmentList.items - ); - const canvassAssignmentItem = canvassAssignmenList.find( - (item) => item.id == canvassAssId - ); - - return loadItemIfNecessary(canvassAssignmentItem, dispatch, { - actionOnLoad: () => canvassAssignmentLoad(canvassAssId), - actionOnSuccess: (data) => canvassAssignmentLoaded(data), - loader: () => - apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}` - ), - }); -} diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentMutations.ts b/src/features/canvassAssignments/hooks/useCanvassAssignmentMutations.ts deleted file mode 100644 index 98cf8a038e..0000000000 --- a/src/features/canvassAssignments/hooks/useCanvassAssignmentMutations.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { - ZetkinCanvassAssignment, - ZetkinCanvassAssignmentPatchbody, -} from '../types'; -import { canvassAssignmentDeleted, canvassAssignmentUpdated } from '../store'; - -export default function useCanvassAssignmentMutations( - orgId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return { - deleteCanvassAssignment: async () => { - await apiClient.delete( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}` - ); - dispatch(canvassAssignmentDeleted(parseInt(canvassAssId))); - }, - updateCanvassAssignment: async (data: ZetkinCanvassAssignmentPatchbody) => { - const updated = await apiClient.patch< - ZetkinCanvassAssignment, - ZetkinCanvassAssignmentPatchbody - >(`/beta/orgs/${orgId}/canvassassignments/${canvassAssId}`, data); - - dispatch(canvassAssignmentUpdated(updated)); - }, - }; -} diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts b/src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts deleted file mode 100644 index e326343414..0000000000 --- a/src/features/canvassAssignments/hooks/useCanvassAssignmentStats.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { loadItemIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { ZetkinCanvassAssignmentStats } from '../types'; -import { statsLoad, statsLoaded } from '../store'; - -export default function useCanvassAssignmentStats( - orgId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const statsItem = useAppSelector( - (state) => state.canvassAssignments.statsByCanvassAssId[canvassAssId] - ); - - return loadItemIfNecessary(statsItem, dispatch, { - actionOnLoad: () => statsLoad(canvassAssId), - actionOnSuccess: (data) => statsLoaded([canvassAssId, data]), - loader: () => - apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/stats` - ), - }); -} diff --git a/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx b/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx deleted file mode 100644 index 119e088645..0000000000 --- a/src/features/canvassAssignments/hooks/useCanvassAssignmentStatus.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import dayjs from 'dayjs'; - -import useCanvassAssignment from './useCanvassAssignment'; - -export enum CanvassAssignmentState { - CLOSED = 'closed', - DRAFT = 'draft', - OPEN = 'open', - SCHEDULED = 'scheduled', - UNKNOWN = 'unknown', -} - -export default function useCanvassAssignmentStatus( - orgId: number, - canvassId: string -): CanvassAssignmentState { - const { data: canvassAssignment } = useCanvassAssignment(orgId, canvassId); - - if (!canvassAssignment) { - return CanvassAssignmentState.UNKNOWN; - } - - const now = dayjs(); - - if (!canvassAssignment.start_date) { - return CanvassAssignmentState.DRAFT; - } - - const startDate = dayjs(canvassAssignment.start_date); - - if (startDate.isAfter(now)) { - return CanvassAssignmentState.SCHEDULED; - } - - if (canvassAssignment.end_date) { - const endDate = dayjs(canvassAssignment.end_date); - - if (endDate.isBefore(now)) { - return CanvassAssignmentState.CLOSED; - } - - if (startDate.isBefore(now) || startDate.isSame(now)) { - return CanvassAssignmentState.OPEN; - } - } - - if (!canvassAssignment.end_date && startDate.isBefore(now)) { - return CanvassAssignmentState.OPEN; - } - - return CanvassAssignmentState.UNKNOWN; -} diff --git a/src/features/canvassAssignments/hooks/useCanvassSessions.ts b/src/features/canvassAssignments/hooks/useCanvassSessions.ts deleted file mode 100644 index d2b944a249..0000000000 --- a/src/features/canvassAssignments/hooks/useCanvassSessions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { ZetkinCanvassSession } from '../types'; -import { canvassSessionsLoad, canvassSessionsLoaded } from '../store'; - -export default function useCanvassSessions( - orgId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const sessions = useAppSelector( - (state) => state.canvassAssignments.sessionsByAssignmentId[canvassAssId] - ); - - return loadListIfNecessary(sessions, dispatch, { - actionOnLoad: () => dispatch(canvassSessionsLoad(canvassAssId)), - - actionOnSuccess: (data) => - dispatch(canvassSessionsLoaded([canvassAssId, data])), - loader: () => - apiClient.get( - `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions` - ), - }); -} diff --git a/src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts b/src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts deleted file mode 100644 index 866ba9502e..0000000000 --- a/src/features/canvassAssignments/hooks/useCreateCanvassAssignment.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { - ZetkinCanvassAssignment, - ZetkinCanvassAssignmentPostBody, -} from '../types'; -import { canvassAssignmentCreated } from '../store'; - -export default function useCreateCanvassAssignment(orgId: number) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return async (data: ZetkinCanvassAssignmentPostBody) => { - const created = await apiClient.post< - ZetkinCanvassAssignment, - ZetkinCanvassAssignmentPostBody - >(`/beta/orgs/${orgId}/canvassassignments`, data); - dispatch(canvassAssignmentCreated(created)); - }; -} diff --git a/src/features/canvassAssignments/hooks/useCreateCanvassSession.ts b/src/features/canvassAssignments/hooks/useCreateCanvassSession.ts deleted file mode 100644 index 411dd691de..0000000000 --- a/src/features/canvassAssignments/hooks/useCreateCanvassSession.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { ZetkinCanvassSession, ZetkinCanvassSessionPostBody } from '../types'; -import { canvassSessionCreated } from '../store'; - -export default function useCreateCanvassSession( - orgId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return async (data: ZetkinCanvassSessionPostBody) => { - const created = await apiClient.post< - ZetkinCanvassSession, - ZetkinCanvassSessionPostBody - >(`/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions`, data); - dispatch(canvassSessionCreated(created)); - }; -} diff --git a/src/features/canvassAssignments/hooks/useCreatePlace.ts b/src/features/canvassAssignments/hooks/useCreatePlace.ts deleted file mode 100644 index 4297a13645..0000000000 --- a/src/features/canvassAssignments/hooks/useCreatePlace.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { ZetkinPlace, ZetkinPlacePostBody } from '../types'; -import { placeCreated } from '../store'; - -export default function useCreatePlace(orgId: number) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return async function createPlace(data: ZetkinPlacePostBody) { - const created = await apiClient.post( - `/beta/orgs/${orgId}/places`, - data - ); - dispatch(placeCreated(created)); - }; -} diff --git a/src/features/canvassAssignments/hooks/usePlaceMutations.ts b/src/features/canvassAssignments/hooks/usePlaceMutations.ts deleted file mode 100644 index 1b8e1cdaa3..0000000000 --- a/src/features/canvassAssignments/hooks/usePlaceMutations.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from 'react'; - -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { - HouseholdPatchBody, - Visit, - ZetkinPlace, - ZetkinPlacePatchBody, - ZetkinPlaceVisit, - ZetkinPlaceVisitPostBody, -} from '../types'; -import { placeUpdated, visitCreated } from '../store'; -import createHouseholds from '../rpc/createHouseholds/client'; - -export default function usePlaceMutations(orgId: number, placeId: string) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const [isAddVisitLoading, setIsAddVisitLoading] = useState(false); - - return { - addHousehold: async () => { - const place = await apiClient.post( - `/beta/orgs/${orgId}/places/${placeId}/households`, - {} - ); - dispatch(placeUpdated(place)); - return place.households[0]; - }, - addHouseholds: async (households: { floor: number; title: string }[]) => { - const place = await apiClient.rpc(createHouseholds, { - households, - orgId, - placeId, - }); - dispatch(placeUpdated(place)); - }, - addVisit: async ( - householdId: string, - data: Omit - ) => { - setIsAddVisitLoading(true); - const place = await apiClient.post< - ZetkinPlace, - Omit - >( - `/beta/orgs/${orgId}/places/${placeId}/households/${householdId}/visits`, - data - ); - dispatch(placeUpdated(place)); - setIsAddVisitLoading(false); - }, - isAddVisitLoading, - reportPlaceVisit: async ( - canvassAssId: string, - data: ZetkinPlaceVisitPostBody - ) => { - const visit = await apiClient.post< - ZetkinPlaceVisit, - ZetkinPlaceVisitPostBody - >(`/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/visits`, data); - dispatch(visitCreated(visit)); - }, - updateHousehold: async (householdId: string, data: HouseholdPatchBody) => { - const place = await apiClient.patch( - `/beta/orgs/${orgId}/places/${placeId}/households/${householdId}`, - data - ); - dispatch(placeUpdated(place)); - }, - updatePlace: async (data: ZetkinPlacePatchBody) => { - const place = await apiClient.patch( - `/beta/orgs/${orgId}/places/${placeId}`, - data - ); - dispatch(placeUpdated(place)); - }, - }; -} diff --git a/src/features/canvassAssignments/hooks/usePlaces.ts b/src/features/canvassAssignments/hooks/usePlaces.ts deleted file mode 100644 index c8fc28ace4..0000000000 --- a/src/features/canvassAssignments/hooks/usePlaces.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { ZetkinPlace } from '../types'; -import { placesLoad, placesLoaded } from '../store'; - -export default function usePlaces(orgId: number) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const placeList = useAppSelector( - (state) => state.canvassAssignments.placeList - ); - - return loadListIfNecessary(placeList, dispatch, { - actionOnLoad: () => placesLoad(), - actionOnSuccess: (data) => placesLoaded(data), - loader: () => apiClient.get(`/beta/orgs/${orgId}/places`), - }); -} diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx deleted file mode 100644 index 1a63863c3b..0000000000 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Box } from '@mui/system'; -import router, { useRouter } from 'next/router'; -import { Button, Typography } from '@mui/material'; -import { FC, ReactNode, useContext } from 'react'; -import { Delete, Pentagon, People } from '@mui/icons-material'; - -import AssignmentStatusChip from '../components/AssignmentStatusChip'; -import getCanvassers from '../utils/getCanvassers'; -import TabbedLayout from 'utils/layout/TabbedLayout'; -import useCanvassAssignment from '../hooks/useCanvassAssignment'; -import useCanvassAssignmentMutations from '../hooks/useCanvassAssignmentMutations'; -import useCanvassSessions from '../hooks/useCanvassSessions'; -import useCanvassAssignmentStats from '../hooks/useCanvassAssignmentStats'; -import useStartEndAssignment from '../hooks/useStartEndAssignment'; -import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; -import ZUIFuture from 'zui/ZUIFuture'; -import ZUIDateRangePicker from 'zui/ZUIDateRangePicker/ZUIDateRangePicker'; -import useCanvassAssignmentStatus, { - CanvassAssignmentState, -} from '../hooks/useCanvassAssignmentStatus'; -import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; -import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; - -type CanvassAssignmentLayoutProps = { - campId: number; - canvassAssId: string; - children: ReactNode; - orgId: number; -}; - -const CanvassAssignmentLayout: FC = ({ - children, - orgId, - campId, - canvassAssId, -}) => { - const path = useRouter().pathname; - const canvassAssignment = useCanvassAssignment(orgId, canvassAssId).data; - const { deleteCanvassAssignment, updateCanvassAssignment } = - useCanvassAssignmentMutations(orgId, canvassAssId); - - const allSessions = useCanvassSessions(orgId, canvassAssId).data || []; - const sessions = allSessions.filter( - (session) => session.assignment.id === canvassAssId - ); - - const stats = useCanvassAssignmentStats(orgId, canvassAssId); - const state = useCanvassAssignmentStatus(orgId, canvassAssId); - const { startAssignment, endAssignment } = useStartEndAssignment( - orgId, - canvassAssId - ); - const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); - - const canvassers = getCanvassers(sessions); - - const isMapTab = path.endsWith('/map'); - - if (!canvassAssignment) { - return null; - } - - const handleDelete = () => { - deleteCanvassAssignment(); - router.push( - `/organize/${orgId}/projects/${canvassAssignment.campaign.id || ''} ` - ); - }; - - return ( - - {state == CanvassAssignmentState.OPEN ? ( - - ) : ( - - )} - { - showConfirmDialog({ - onSubmit: handleDelete, - title: 'Delete', - warningText: `Are you sure you want to delete ${canvassAssignment.title}?`, - }); - }, - startIcon: , - }, - ]} - /> - - } - baseHref={`/organize/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}`} - belowActionButtons={ - { - updateCanvassAssignment({ - end_date: endDate, - start_date: startDate, - }); - }} - startDate={canvassAssignment.start_date || null} - /> - } - defaultTab="/" - fixedHeight={isMapTab} - subtitle={ - - - - - - {(data) => ( - - - {data.num_areas} Area(s) - - )} - - - - - {canvassers.length} Canvasser(s) - - - - } - tabs={[ - { href: '/', label: 'Overview' }, - { href: '/map', label: 'Map' }, - { href: '/canvassers', label: 'Canvassers' }, - { href: '/outcomes', label: 'Outcomes' }, - { href: '/instructions', label: 'Instructions' }, - ]} - title={ - updateCanvassAssignment({ title: newTitle })} - value={canvassAssignment.title || 'Untitled canvass assignment'} - /> - } - > - {children} - - ); -}; - -export default CanvassAssignmentLayout; diff --git a/src/features/canvassAssignments/store.ts b/src/features/canvassAssignments/store.ts deleted file mode 100644 index ec796b1d5c..0000000000 --- a/src/features/canvassAssignments/store.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -import { - findOrAddItem, - RemoteItem, - remoteItem, - remoteList, - RemoteList, -} from 'utils/storeUtils'; -import { - AreaCardData, - ZetkinCanvassAssignmentStats, - ZetkinCanvassAssignment, - ZetkinCanvassSession, - ZetkinPlace, - AssignmentWithAreas, - ZetkinAssignmentAreaStats, - SessionDeletedPayload, - ZetkinPlaceVisit, -} from './types'; - -export interface CanvassAssignmentsStoreSlice { - areaGraphByAssignmentId: Record< - string, - RemoteList - >; - areaStatsByAssignmentId: Record< - string, - RemoteItem - >; - canvassAssignmentList: RemoteList; - sessionsByAssignmentId: Record< - string, - RemoteList - >; - myAssignmentsWithAreasList: RemoteList; - placeList: RemoteList; - statsByCanvassAssId: Record< - string, - RemoteItem - >; - visitsByAssignmentId: Record>; -} - -const initialState: CanvassAssignmentsStoreSlice = { - areaGraphByAssignmentId: {}, - areaStatsByAssignmentId: {}, - canvassAssignmentList: remoteList(), - myAssignmentsWithAreasList: remoteList(), - placeList: remoteList(), - sessionsByAssignmentId: {}, - statsByCanvassAssId: {}, - visitsByAssignmentId: {}, -}; - -const canvassAssignmentSlice = createSlice({ - initialState: initialState, - name: 'canvassAssignments', - reducers: { - areaGraphLoad: (state, action: PayloadAction) => { - const assignmentId = action.payload; - - if (!state.areaGraphByAssignmentId[assignmentId]) { - state.areaGraphByAssignmentId[assignmentId] = remoteList(); - } - - state.areaGraphByAssignmentId[assignmentId].isLoading = true; - }, - areaGraphLoaded: ( - state, - action: PayloadAction<[string, AreaCardData[]]> - ) => { - const [assignmentId, graphData] = action.payload; - - state.areaGraphByAssignmentId[assignmentId] = remoteList( - graphData.map((data) => ({ ...data, id: data.area.id })) - ); - - state.areaGraphByAssignmentId[assignmentId].loaded = - new Date().toISOString(); - }, - areaStatsLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - - if (!state.areaStatsByAssignmentId[canvassAssId]) { - state.areaStatsByAssignmentId[canvassAssId] = remoteItem(canvassAssId); - } - const statsItem = state.areaStatsByAssignmentId[canvassAssId]; - - state.areaStatsByAssignmentId[canvassAssId] = remoteItem(canvassAssId, { - data: statsItem?.data || null, - isLoading: true, - }); - }, - areaStatsLoaded: ( - state, - action: PayloadAction<[string, ZetkinAssignmentAreaStats]> - ) => { - const [canvassAssId, stats] = action.payload; - - state.areaStatsByAssignmentId[canvassAssId] = remoteItem(canvassAssId, { - data: { id: canvassAssId, ...stats }, - isLoading: false, - isStale: false, - 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); - }, - canvassAssignmentDeleted: (state, action: PayloadAction) => { - const canvassId = action.payload; - const canvassAssignmentItem = state.canvassAssignmentList.items.find( - (item) => item.id === canvassId - ); - - if (canvassAssignmentItem) { - canvassAssignmentItem.deleted = true; - } - }, - 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); - - const hasStatsItem = !!state.areaStatsByAssignmentId[ - session.assignment.id - ].data?.stats.find((statsItem) => statsItem.areaId == session.area.id); - - if (!hasStatsItem) { - state.areaStatsByAssignmentId[session.assignment.id].isStale = true; - } - }, - 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.myAssignmentsWithAreasList.isLoading = true; - }, - myAssignmentsLoaded: ( - state, - action: PayloadAction - ) => { - const assignments = action.payload; - const timestamp = new Date().toISOString(); - - state.myAssignmentsWithAreasList = remoteList(assignments); - state.myAssignmentsWithAreasList.loaded = timestamp; - state.myAssignmentsWithAreasList.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(); - }, - placesInvalidated: (state) => { - state.placeList.isStale = true; - }, - 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)); - }, - sessionDeleted: (state, action: PayloadAction) => { - const { areaId, assignmentId, assigneeId } = action.payload; - - const sessionsList = state.sessionsByAssignmentId[assignmentId]; - - if (sessionsList) { - const filteredSessions = sessionsList.items.filter( - (item) => - !( - item.data?.area.id === areaId && - item.data?.assignee.id === assigneeId - ) - ); - state.sessionsByAssignmentId[assignmentId] = { - ...sessionsList, - items: filteredSessions, - }; - } - }, - statsLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - - if (!state.statsByCanvassAssId[canvassAssId]) { - state.statsByCanvassAssId[canvassAssId] = remoteItem(canvassAssId); - } - 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(), - }); - }, - visitCreated: (state, action: PayloadAction) => { - const visit = action.payload; - const assignmentId = visit.canvassAssId; - if (!state.visitsByAssignmentId[assignmentId]) { - state.visitsByAssignmentId[assignmentId] = remoteList(); - } - - state.visitsByAssignmentId[assignmentId].items.push( - remoteItem(visit.id, { data: visit }) - ); - }, - visitsInvalidated: (state, action: PayloadAction) => { - const assignmentId = action.payload; - state.visitsByAssignmentId[assignmentId].isStale = true; - }, - visitsLoad: (state, action: PayloadAction) => { - state.visitsByAssignmentId[action.payload] = remoteList(); - state.visitsByAssignmentId[action.payload].isLoading = true; - }, - visitsLoaded: ( - state, - action: PayloadAction<[string, ZetkinPlaceVisit[]]> - ) => { - const [placeId, visits] = action.payload; - state.visitsByAssignmentId[placeId] = remoteList(visits); - state.visitsByAssignmentId[placeId].isLoading = false; - state.visitsByAssignmentId[placeId].loaded = new Date().toISOString(); - }, - }, -}); - -export default canvassAssignmentSlice; -export const { - areaGraphLoad, - areaGraphLoaded, - areaStatsLoad, - areaStatsLoaded, - myAssignmentsLoad, - myAssignmentsLoaded, - canvassAssignmentCreated, - canvassAssignmentDeleted, - canvassAssignmentLoad, - canvassAssignmentLoaded, - canvassAssignmentUpdated, - canvassAssignmentsLoad, - canvassAssignmentsLoaded, - canvassSessionCreated, - canvassSessionsLoad, - canvassSessionsLoaded, - placeCreated, - placesInvalidated, - placesLoad, - placesLoaded, - placeUpdated, - sessionDeleted, - statsLoad, - statsLoaded, - visitCreated, - visitsInvalidated, - visitsLoad, - visitsLoaded, -} = canvassAssignmentSlice.actions; diff --git a/src/features/canvassAssignments/utils/getDoneState.ts b/src/features/canvassAssignments/utils/getDoneState.ts deleted file mode 100644 index 215e67fe39..0000000000 --- a/src/features/canvassAssignments/utils/getDoneState.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Household } from '../types'; -import { ProgressState } from './getVisitState'; - -export default function getDoneState( - households: Household[], - canvassAssId: string, - metricId: string -): ProgressState { - let numberOfDoneHouseholds = 0; - households.forEach((household) => { - household.visits.forEach((visit) => { - if (visit.canvassAssId == canvassAssId) { - const done = visit.responses.find( - (response) => - response.metricId == metricId && response.response == 'yes' - ); - - if (done) { - numberOfDoneHouseholds++; - } - } - }); - }); - - if ( - numberOfDoneHouseholds > 0 && - numberOfDoneHouseholds == households.length - ) { - return 'all'; - } else if ( - numberOfDoneHouseholds > 0 && - numberOfDoneHouseholds < households.length - ) { - return 'some'; - } else { - return 'none'; - } -} diff --git a/src/features/emails/components/EmailEditor/index.tsx b/src/features/emails/components/EmailEditor/index.tsx index c25365026f..c892679ff6 100644 --- a/src/features/emails/components/EmailEditor/index.tsx +++ b/src/features/emails/components/EmailEditor/index.tsx @@ -88,6 +88,7 @@ const EmailEditor: FC = ({ email, onSave }) => { { diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/geography/components/GeographyMap/MapRenderer.tsx similarity index 100% rename from src/features/areas/components/AreasMap/MapRenderer.tsx rename to src/features/geography/components/GeographyMap/MapRenderer.tsx diff --git a/src/features/areas/components/AreasMap/index.tsx b/src/features/geography/components/GeographyMap/index.tsx similarity index 84% rename from src/features/areas/components/AreasMap/index.tsx rename to src/features/geography/components/GeographyMap/index.tsx index 3141bff330..fdc65f10a0 100644 --- a/src/features/areas/components/AreasMap/index.tsx +++ b/src/features/geography/components/GeographyMap/index.tsx @@ -16,20 +16,23 @@ import 'leaflet/dist/leaflet.css'; import { useNumericRouteParams } from 'core/hooks'; import objToLatLng from 'features/areas/utils/objToLatLng'; -import useCreateArea from '../../hooks/useCreateArea'; -import { PointData, ZetkinArea } from '../../types'; -import AreaFilters from '../AreaFilters'; -import AreaOverlay from '../AreaOverlay'; +import useCreateArea from '../../../areas/hooks/useCreateArea'; +import { PointData, ZetkinArea } from '../../../areas/types'; +import AreaFilters from '../../../areas/components/AreaFilters'; +import AreaOverlay from '../../../areas/components/AreaOverlay'; import MapRenderer from './MapRenderer'; -import AreaFilterProvider from '../AreaFilters/AreaFilterContext'; -import AreaFilterButton from '../AreaFilters/AreaFilterButton'; -import MapControls from 'features/canvassAssignments/components/MapControls'; +import AreaFilterProvider from '../../../areas/components/AreaFilters/AreaFilterContext'; +import AreaFilterButton from '../../../areas/components/AreaFilters/AreaFilterButton'; +import MapControls from 'features/areaAssignments/components/MapControls'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; interface MapProps { areas: ZetkinArea[]; } -const Map: FC = ({ areas }) => { +const GeographyMap: FC = ({ areas }) => { + const messages = useMessages(messageIds); const mapRef = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); const [selectedId, setSelectedId] = useState(''); @@ -67,7 +70,11 @@ const Map: FC = ({ areas }) => { async function finishDrawing() { if (drawingPoints && drawingPoints.length > 2) { - const area = await createArea({ points: drawingPoints }); + const area = await createArea({ + description: '', + points: drawingPoints, + title: messages.areas.default.title(), + }); setSelectedId(area.id); } setDrawingPoints(null); @@ -80,11 +87,11 @@ const Map: FC = ({ areas }) => { inputValue.length == 0 ? areas.concat() : areas.filter((area) => { - const areaTitle = area.title || 'Untitled area'; - const areaDesc = area.description || 'Empty description'; + const areaDesc = + area.description || messages.areas.default.description(); return ( - areaTitle.toLowerCase().includes(inputValue) || + area.title.toLowerCase().includes(inputValue) || areaDesc.toLowerCase().includes(inputValue) ); }); @@ -141,7 +148,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - Draw + )} {drawingPoints && ( @@ -151,7 +158,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - Cancel + )} {drawingPoints && drawingPoints.length > 2 && ( @@ -161,7 +168,7 @@ const Map: FC = ({ areas }) => { }} startIcon={} > - Save + )} @@ -198,7 +205,7 @@ const Map: FC = ({ areas }) => { /> )} renderOption={(props, area) => ( - {area.title || 'Untitled area'} + {area.title} )} value={null} /> @@ -253,4 +260,4 @@ const Map: FC = ({ areas }) => { ); }; -export default Map; +export default GeographyMap; diff --git a/src/features/home/components/MyActivitiesList.tsx b/src/features/home/components/MyActivitiesList.tsx index 41aa9afe1b..3e719e89f9 100644 --- a/src/features/home/components/MyActivitiesList.tsx +++ b/src/features/home/components/MyActivitiesList.tsx @@ -110,15 +110,14 @@ const MyActivitiesList: FC = () => { - + , ]} href={href} Icon={MapsHomeWork} info={[]} title={ - activity.data.title || - messages.defaultTitles.canvassAssignment() + activity.data.title || messages.defaultTitles.areaAssignment() } /> ); diff --git a/src/features/home/hooks/useMyActivities.ts b/src/features/home/hooks/useMyActivities.ts index 680daba27f..d3bf05a657 100644 --- a/src/features/home/hooks/useMyActivities.ts +++ b/src/features/home/hooks/useMyActivities.ts @@ -1,15 +1,15 @@ -import useMyCanvassAssignments from 'features/canvassAssignments/hooks/useMyCanvassAssignments'; +import useMyAreaAssignments from 'features/canvass/hooks/useMyAreaAssignments'; import useMyCallAssignments from 'features/callAssignments/hooks/useMyCallAssignments'; import useMyEvents from 'features/events/hooks/useMyEvents'; import { MyActivity } from '../types'; export default function useMyActivities() { - const canvassAssignments = useMyCanvassAssignments(); + const areaAssignments = useMyAreaAssignments(); const callAssignments = useMyCallAssignments(); const events = useMyEvents(); const activities: MyActivity[] = [ - ...canvassAssignments.map((data) => ({ + ...areaAssignments.map((data) => ({ data, kind: 'canvass', start: new Date(data.start_date || 0), diff --git a/src/features/home/l10n/messageIds.ts b/src/features/home/l10n/messageIds.ts index 9d5659fd7a..32f59138b9 100644 --- a/src/features/home/l10n/messageIds.ts +++ b/src/features/home/l10n/messageIds.ts @@ -3,8 +3,8 @@ import { m, makeMessages } from 'core/i18n/messages'; export default makeMessages('feat.home', { activityList: { actions: { + areaAssignment: m('Start assignment'), call: m('Start calling'), - canvass: m('Start canvassing'), signUp: m('Sign up'), undoSignup: m('Undo signup'), }, @@ -18,7 +18,7 @@ export default makeMessages('feat.home', { }, filters: { call: m('Call'), - canvass: m('Canvass'), + canvass: m('Areas'), event: m('Events'), }, }, @@ -37,8 +37,8 @@ export default makeMessages('feat.home', { }, }, defaultTitles: { + areaAssignment: m('Untitled area assignment'), callAssignment: m('Untitled call assignment'), - canvassAssignment: m('Untitled canvass assignment'), event: m('Untitled event'), noLocation: m('No physical location'), }, diff --git a/src/features/home/types.ts b/src/features/home/types.ts index a5b79e5bc7..611c48c00f 100644 --- a/src/features/home/types.ts +++ b/src/features/home/types.ts @@ -1,4 +1,4 @@ -import { ZetkinCanvassAssignment } from 'features/canvassAssignments/types'; +import { ZetkinAreaAssignment } from 'features/areaAssignments/types'; import { ZetkinCallAssignment, ZetkinEvent } from 'utils/types/zetkin'; type MyEventActivity = { @@ -13,8 +13,8 @@ type MyCallAssignmentActivity = { start: Date; }; -type MyCanvassAssignmentActivity = { - data: ZetkinCanvassAssignment; +type MyAreaAssignmentActivity = { + data: ZetkinAreaAssignment; kind: 'canvass'; start: Date; }; @@ -22,7 +22,7 @@ type MyCanvassAssignmentActivity = { export type MyActivity = | MyEventActivity | MyCallAssignmentActivity - | MyCanvassAssignmentActivity; + | MyAreaAssignmentActivity; export type ZetkinEventWithStatus = ZetkinEvent & { status: 'signedUp' | 'booked' | null; diff --git a/src/features/surveys/components/SurveyEditor/elements/PreviewableSurveyInput.tsx b/src/features/surveys/components/SurveyEditor/elements/PreviewableSurveyInput.tsx index 32c107c22c..b14338bdc2 100644 --- a/src/features/surveys/components/SurveyEditor/elements/PreviewableSurveyInput.tsx +++ b/src/features/surveys/components/SurveyEditor/elements/PreviewableSurveyInput.tsx @@ -42,6 +42,7 @@ const PreviewableSurveyInput: FC = ({ fullWidth inputProps={{ ...props, sx: VARIANTS[variant] }} label={label} + multiline={variant === 'content'} onChange={(ev) => onChange(ev.target.value)} sx={{ marginBottom: 2 }} value={value} diff --git a/src/locale/de.yml b/src/locale/de.yml index f2a951b537..d79d9989a7 100644 --- a/src/locale/de.yml +++ b/src/locale/de.yml @@ -40,6 +40,7 @@ feat: callassignments: Telefonaktionen callers: Telefonierer*innen campaigns: Projekte + canvassassignments: Haustür-Aufgabe closed: Beendet compose: Erstellen conversation: Gespräch @@ -151,8 +152,10 @@ feat: today: Heute callAssignments: actions: + delete: Löschen end: Aufgabe beenden start: Aufgabe starten + warning: '"{title}" wird gelöscht.' blocked: callBackLater: Möchte später zurückgerufen werden calledTooRecently: Kürzlich angerufen @@ -170,6 +173,8 @@ feat: remove: Von Aufgabe entfernen add: alreadyAdded: Bereits hinzugefügt + placeholder: Beginne zu tippen, um zu suchen oder eine_n neue_n Anrufer_in + hinzuzufügen. customize: exclude: h: Ausgeschlossene Schlagworte @@ -306,7 +311,7 @@ feat: feedback: copy: Feedback einzuholen kann dein Engagement effektiver machen create: Umfrage erstellen - heading: Feedback und Umfragen + heading: Feedback und Umfragen (nicht eingestellt) form: createCallAssignment: newCallAssignment: Meine Telefonaktionen @@ -314,6 +319,8 @@ feat: create: Erstelle Projekt error: Ein Fehler ist aufgetreten beim Erstellen des Projekts newCampaign: Mein Projekt + createCanvassAssignment: + defaultQuestion: Bist du mit deiner Mission fertig geworden? createEmail: newEmail: unbenannte E-Mail createSurvey: @@ -428,7 +435,7 @@ feat: title: Duplikate zusammenführen warningMessage: Alle Daten, die mit dem Personendatensatz verbunden sind, werden zu der zusammengeführten Person transferiert. Das beinhaltet die Teilnahme - an Veranstaltungen, Mitgliederbefragungen, Schlagworte, usw. Aber die Werte, + an Veranstaltungen, Mitgliederumfragen, Schlagworte, usw. Aber die Werte, die du in den obenstehenden Feldern nicht mit übernimmst, sind dann weg. warningTitle: Achtung, Risiko von Datenverlust. page: @@ -853,6 +860,11 @@ feat: states: added: werden zu dieser Veranstaltung hinzugefügt pending: In der Datenbasis + removed: Weggehen von dieser Veranstaltung + statusText: '{personCount, plural, =1 {Eine Person} other {# Personen}} werden + umsortiert' + submitButton: ausführen + title: Verwalte Teilnehmer_innen search: Suche state: cancelled: Abgesagt @@ -868,6 +880,10 @@ feat: tooltipContent: Unbenannte Aktionen zeigen den Typ als Überschrift an type: createType: Erstelle "{type}" + deleteMessage: Bist du dir sicher, dass du die "{eventType}" Veranstaltungsart + für die gesamte Organisation löschen möchtest? + deleteWarning: Bist du dir sicher, dass du die "{eventType}" Veranstaltungsart + für die gesamte {orgTitle} löschen möchtest? tooltip: Klicke, um Typ zu ändern uncategorized: Nicht kategorisiert files: @@ -875,6 +891,8 @@ feat: dropToUpload: Hier die Datei hineinziehen, um sie hochzuladen. instructions: Oder drag und drop selectClick: Klick, um hochzuladen + image: + dimensions: '{width} × {height} Pixel' libraryDialog: preview: backButton: Zurück zur Bibliothek @@ -896,6 +914,7 @@ feat: activityList: actions: call: Beginne mit dem Anrufen + canvass: Beginn mit dem Haustüreinsatz signUp: Anmelden undoSignup: Abmelden emptyListMessage: Du bist für keine Aktivitäten angemeldet @@ -906,6 +925,7 @@ feat: signedUp: Du hast dich angemeldet. filters: call: Anrufen + canvass: Haustüreinsatz event: Veranstaltungen allEventsList: emptyList: @@ -919,6 +939,7 @@ feat: tomorrow: Morgen defaultTitles: callAssignment: Unbenannte Anruf-Aufgabe + canvassAssignment: Unbenannter Haustüreinsatz event: Unbenannte Veranstaltung noLocation: Kein Ort. footer: @@ -944,14 +965,20 @@ feat: um die Werte zu trennen. Wenn dein Datum beispielsweise in dem Format 1998.03.23 sein soll, würdest du es beschreiben als YYYY.MM.DD. customFormatLabel: Eigenes Datumsformat + dateConfigDescription: Wähle das Format der Werte in dieser Spalte, so dass + sie als richtiges Datum importiert werden können. dateInputLabel: Datumsformat dropDownLabel: Format auswählen emptyPreview: Konnte nicht verarbeitet werden. header: Konfiguriere das Datumsformat + invalidDateFormatWarning: Es gibt Werte in dieser Spalte, die diesem Format + nicht zu entsprechen scheinen. Bist du sicher, dass du das richtige Format + gewählt hast? listSubHeaders: custom: benutzerdefiniert dates: Datumsformat personNumbers: Anzahl der Personen + noCustomFormatWarning: Du hast kein angepasstes Datumsformat angegeben. personNumberFormat: dk: description: Die Werte in dieser Spalte werden von 10stelliger dänischer @@ -985,6 +1012,9 @@ feat: externalID: Externe ID externalIDExplanation: Die Werte in dieser Spalte sind IDs von unserem Mitgliederprogramm (nicht Zetkin). + externalIDInfo: Eine externe ID ist eine ID, die von einem anderen System + als Zetkin kommt, wie beispielsweise eine separate Mitgliedsnummer. Sie + kann benutzt werden, um Personen in Zetkin zu identifizieren. header: Konfiguriere IDs showOrganizationSelectButton: zuordnen zu ... wrongIDFormatWarning: Die Werte in dieser Spalte sehen nicht aus wie Zetkin @@ -993,10 +1023,14 @@ feat: zetkinID: Zetkin ID zetkinIDExplanation: Die Werte in dieser Spalte basieren auf einem Export von Zetkin. + zetkinIDInfo: Eine Zetkin ID ist die ID einer Person, die bereits in Zetkin + existiert. Du würdest diese Information in einer Datei haben, wenn du + die Daten aus Zetkin exportiert hattest. orgs: guess: Vermute Organisationen header: Ordner Werte den Organisationen zu. organizations: Organisation + showOrganizationSelectButton: Zuordnen zu... status: Status tags: empty: Leer @@ -1009,6 +1043,7 @@ feat: configButton: Konfigurieren defaultColumnHeader: Spalte {columnIndex} emptyStateMessage: Anfangen durch Zuordnen von Dateispalten + externalID: Externe ID fileHeader: Datei finishedMappingDates: Zuordnen von {numValues, plural, =1 {1 value} other {# values}} from {dateFormat, select, se {Swedish personnummer} no {Norwegian @@ -1022,6 +1057,7 @@ feat: to {numMappedTo, plural, =1 {1 tag} other {# tags}} header: Zuordnen id: ID + infoButton: Information mapValuesButton: Zuordnungswerte messages: manyValuesAndEmpty: '{firstValue}, {secondValue}, {thirdValue}, {numMoreValues, @@ -1032,12 +1068,21 @@ feat: oneValueAndEmpty: '{firstValue} and {numEmpty, plural, =1 {eine leere Zeile} other {# leere Zeilen}}.' oneValueNoEmpty: '{firstValue}.' + onlyEmpty: '{numEmpty, plural, =1 {eine leere Zeile} other {# leere Zeilen}}.' + readOnlyField: '{title} (nicht veränderbar)' + threeValuesAndEmpty: '{firstValue}, {secondValue}, {thirdValue} und {numEmpty, + plural, =1 {eine leere Zeile} other {# leere Zeilen}}.' + threeValuesNoEmpty: '{firstValue}, {secondValue} und {thirdValue}.' + twoValuesAndEmpty: '{firstValue}, {secondValue} und {numEmpty, plural, =1 + {eine leere Zeile} other {# leere Zeilen}}.' + twoValuesNoEmpty: '{firstValue} und {secondValue}.' organization: Organisation selectZetkinField: Importiere als ... tags: Schlagworte unfinished: date: Du musst ein Datumsformat konfigurieren. enum: Du musst die Werte zuordnen + gender: Du musst die Werte zuordnen id: Du musst die IDs konfigurieren. org: Du musst die Werte zuordnen tag: Du musst die Werte zuordnen. @@ -1046,10 +1091,17 @@ feat: id: ID other: andere zetkinHeader: Zetkin + zetkinID: Zetkin ID preview: columnHeader: + gender: Geschlecht org: Organisation tags: Schlagworte + genders: + f: Weiblich + m: Männlich + o: Divers + unknown: Keine Angabe next: Nächstes noOrg: Keine Organisation noTags: Keine Schlagworte @@ -1064,10 +1116,15 @@ feat: sheetSelectLabel: Datenblatt show: zeige statusMessage: + done: Konfiguriere den Import von {numConfiguredPeople, plural, =1 {1 Person} + other {# Personen}} notDone: Deine Konfiguration ist unvollständig. title: Personen importieren impactSummary: future: + created: '{number} neue {numPeople, plural, =1 {Person} other {Personen}} + werden erstellt' + defaultDesc: '{numPeople} werden Änderungen in {field} ergänzt.' organization: Organisation orgs: '{numPeople} werden einer {org} zugefügt' tags: Schlagworte @@ -1084,6 +1141,7 @@ feat: tagsDesc: '{numPeople} wurden {tags} hinzugefügt' updated: '{number} {numPeople, plural, =1 {Person} other {Personen}} wurden aktualisiert' + people: '{number} {numPeople, plural, =1 {Person} other {Personen}}' importStatus: completed: desc: Deine Daten wurden in Zetkin importiert @@ -1178,6 +1236,9 @@ feat: statusMessages: create: Dieser Import wird {numCreated, plural, =1 {1 person} other {# people}} erstellen. + createAndUpdate: Dieser Import wird {numCreated, plural, =1 {1 Person} other + {# Personen}} erstellen und {numUpdated, plural, =1 {1 Person} other {# + Personen}} aktualisieren. error: Du musst die Fehler beheben, bevor du importieren kannst. update: Dieser Import wird {numUpdated, plural, =1 {1 person} other {# people}} aktualisieren. @@ -1285,6 +1346,7 @@ feat: closed: Erledigt created: Erstellt id: ID + journey: Anfragevermittlung nextMilestone: Nächster Meilenstein nextMilestoneDeadline: Frist für nächsten Meilenstein outcome: Ergebnis @@ -1314,6 +1376,7 @@ feat: closedCount: '{numberClosed} fertig' conversionSnackbar: error: Etwas ist schief gegangen beim konvertieren. + success: Anfrageübermittlung erfolgreich! editJourneyTitleAlert: error: Fehler. Der Titel wurde nicht aktualisiert. success: Aktualisierter Titel! @@ -1323,6 +1386,8 @@ feat: downloadXlsx: Alles als Excel herunterladen nextMilestone: Nächster Meilenstein openCount: '{numberOpen} offen' + overview: + overviewTitle: Alle Anfragen statusClosed: Erledigt statusOpen: Offen tabs: @@ -1332,9 +1397,23 @@ feat: open: Offen overview: Übersicht timeline: Zeitleiste + title: Anfragen organizations: gen3: + description: Das ist die neue (generation 3) Web App für Organisator*innen. + Wenn du an die alte Version gewöhnt bist, wirst du hier viele neue Funktionen + und eine verbesserte Oberfläche finden. Aber wenn du willst, kannst du immernoch + die alte App für Organisator*innen benutzen. + gen2Button: Gehe zur alten Oberfläche title: Willkommen im neuen Zetkin! + notOrganizer: + description: Dieser Teil von Zetkin ist nur für Benutzer_innen verfügbar, die + Zugang durch einen Zetkin-Administrator bekommen haben. Du hast momentan nicht + ausreichend Zugangsrechte, um hier zugreifen zu können. Wenn das falsch sein + sollte, melde dich bitte bei einer zuständigen Person aus deinem Kreisverband + oder bei der Zetkin Stiftung. + myPageButton: Gehe zum Aktivistenportal + title: Du hast keinen Administrator_innen-Zugang. page: title: 'Organisation auswählen:' sidebar: @@ -1349,6 +1428,8 @@ feat: profile: delete: button: Person entfernen + confirm: Bist du sicher, dass du {name} von {org} und allen damit verbundenen + Organisationen löschen willst? Das kann nicht rückgängig gemacht werden. title: Profil löschen warning: Diese Aktion kann nicht rückgängig gemacht werden! details: @@ -1357,11 +1438,18 @@ feat: editButtonClose: '{title} bearbeiten beenden' editButtonLabel: Details bearbeiten editPersonHeader: Bearbeite {person} + ellipsisMenu: + merge: Zusammenführen mit... genders: f: weiblich m: männlich o: divers unknown: nicht bekannt + journeys: + addButton: Beginne eine neue Anfrage + title: Anfragen + numberOfChangesMessage: Wir werden {number, plural, =1 {1 Feld} other {# Felder}} + aktualisieren organizations: add: Füge neue Unterorganisation hinzu addError: Diese Organisation konnte nicht hinzugefügt werden @@ -1467,7 +1555,7 @@ feat: description: Auswahl auf Grundlage von Telefondaten title: Telefonaktionen surveys: - description: Auswahl auf Grundlage von Umfrageergebnissen + description: Auswahl auf Grundlage von Umfrageantworten title: Umfragen tasks: description: Auswahl auf Grundlage von Daten aus Aufgaben @@ -1520,11 +1608,11 @@ feat: zu verfeinern. title: Personen, die einer gespeicherten dynamischen Suchanfrage entsprechen. survey_option: - description: 'Benutzer Deine Umfrageergebnisse für ihren eigentlichen Zweck: + description: 'Benutze die Umfrageantworten für ihren eigentlichen Zweck: Finde die richtigen Personen!' title: Antworten auf Fragen mit Ankreuzfeldern. survey_response: - description: 'Benutzer Deine Umfrageergebnisse für ihren eigentlichen Zweck: + description: 'Benutze die Umfrageantworten für ihren eigentlichen Zweck: Finde die richtigen Personen!' title: Antworten auf Textfragen survey_submission: @@ -1623,6 +1711,8 @@ feat: any: aus irgendeinem Grund unsubOrg: sie haben sich abgemeldet emailClick: + inputString: '{addRemoveSelect} Personen, die {operatorSelect} {linkScopeSelect} + {emailSelect} {projectSelect} {timeFrame} {linkSelect} haben' linkScopeSelect: anyFollowingLinks: irgendeinen der folgenden Links in der spezifischen E-Mail anyLink: irgendeinen Link in irgendeiner E-Mail @@ -1637,11 +1727,18 @@ feat: any: irgendeine E-Mail email: die spezifische E-Mail project: irgendeine E-Mail vom Projekt + inputString: '{addRemoveSelect} Personen, die {operatorSelect} {emailScopeSelect} + {emailSelect} {projectSelect} {timeFrame} haben' operatorSelect: notOpened: nicht geöffnet notSent: nicht gesendet opened: geöffnet sent: wurde gesendet + joinForm: + anyForm: jedes Zetkin-Beitrittsformular + form: '"{title}"' + inputString: '{addRemoveSelect} Personen, die durch {formSelect} {timeFrame} + zu Zetkin gekommen sind.' journey: condition: conditionSelect: @@ -1657,6 +1754,9 @@ feat: regardlessTags: unabhängig von Schlagworten some: und haben mindestens {minMatching} followingTags: der folgenden Schlagworte + inputString: '{addRemoveSelect} Personen, die Teil sind von {journeySelect}, + das gestartet wurde am {openedTimeFrame} und {stateSelect} {closedTimeFrame} + {condition} {tagsDesc} {tags}' stateOptions: closed: erledigt open: noch offen @@ -1670,6 +1770,7 @@ feat: inputString: '{addRemoveSelect} die {numPeopleSelect} aktivsten {numPeople} in der Organisation {timeFrame}.' numPeople: '{numPeople, plural, one {Person} other {Personen}}' + numPeopleSelect: '{numPeopleSelect}' personData: addRemoveSelect: add: Hinzufügen @@ -1679,7 +1780,7 @@ feat: one: Füge alle Personen hinzu, deren Vorname 'Clara' und deren Nachname 'Zetkin' ist. two: Entferne alle Personen, deren Ort Stockholm ist. - fieldMatches: '{field} entspricht {value}' + fieldMatches: '{field} ist {value}' fieldSelect: alt_phone: Weitere Telefonnummer city: Stadt @@ -1707,16 +1808,16 @@ feat: date: '{fieldSelect} ist {timeFrame}' enum: '{fieldSelect} ist "{selectInput}"' none: Diese Organisation hat bisher keine benutzerdefinierten Felder. - text: '{fieldSelect} entspricht "{freeTextInput}"' - url: '{fieldSelect} entspricht "{freeTextInput}"' + text: '{fieldSelect} ist "{freeTextInput}"' + url: '{fieldSelect} ist "{freeTextInput}"' fieldSelect: any: benutzerdefiniertes Feld inputString: '{addRemoveSelect} jede Person, deren {field}.' preview: date: '{fieldName} ist {timeFrame}' enum: '{fieldName} ist "{searchTerm}"' - text: '{fieldName} entspricht "{searchTerm}"' - url: '{fieldName} entspricht "{searchTerm}"' + text: '{fieldName} ist "{searchTerm}"' + url: '{fieldName} ist "{searchTerm}"' personTags: addRemoveSelect: add: Hinzufügen @@ -1727,11 +1828,18 @@ feat: any: irgendeinem minMatching: mindestens none: keinem + some: schließlich + edit: + all: '{conditionSelect}' + any: '{conditionSelect}' + none: '{conditionSelect}' + some: '{conditionSelect} {minMatchingInput}' preview: all: alle any: irgendeinem minMatching: mindestens {minMatching} none: keinem + some: mindestens {minMatching} examples: one: 'Füge Personen hinzu mit mindestens einem der folgenden Stichwörter: ''Mitglied'', ''Aktivist*in''' @@ -1782,9 +1890,12 @@ feat: edit: callassignment_goal: '{querySelect} der Telefonaktion "{titleSelect}"' callassignment_target: '{querySelect} der Telefonaktion "{titleSelect}"' + none: querySelect + standalone: '{querySelect} "{titleSelect}"' preview: callassignment_goal: die Zielgruppe der Telefonaktion "{queryTitle}" callassignment_target: die Zielgruppe der Telefonaktion "{queryTitle}" + none: '{queryTitle}' standalone: Intelligente Suchanfrage selectLabel: callassignment_goal: Zielgruppe @@ -1805,12 +1916,13 @@ feat: all: alle any: irgendeine none: keine + some: einige examples: one: 'Entferne alle Personen, die alle der folgenden Optionen in der Umfrage - ''Mitgliederumfrage 2020'' (Frage ''Frage 1'') ausgewählt haben: ''manchmal'', + ''Mitgliederbefragung 2020'' (Frage ''Frage 1'') ausgewählt haben: ''manchmal'', ''niemals''' two: 'Füge alle Personen hinzu, die irgendeine der folgenden Optionen in - der Umfrage ''Mitgliederumfrage'' (Frage ''Frage 2'') ausgewählt haben: + der Umfrage ''Mitgliederbefragung'' (Frage ''Frage 2'') ausgewählt haben: ''Option 2''' inputString: '{addRemoveSelect} Personen, die folgende Optionen in {surveySelect} ({questionSelect}) ausgewählt haben {conditionSelect}: {options}' @@ -1821,15 +1933,15 @@ feat: surveySelect: any: eine Umfrage none: Diese Organisation hat bisher keine angelegten Umfragen. - survey: Umfrage "{surveyTitle}" + survey: Umfrage"{surveyTitle}" surveyResponse: addRemoveSelect: add: Hinzufügen sub: Entfernen examples: - one: Personen, deren Antworten in der Umfrage 'Mitgliederumfrage' (irgendeine + one: Personen, deren Antworten in der Umfrage 'Mitgliederbefragung' (irgendeine Frage) 'organize' beinhalten. - two: Personen, deren Antworten in der Umfrage 'Mitgliederumfrage' (Frage + two: Personen, deren Antworten in der Umfrage 'Mitgliederbefragung' (Frage 'Frage 1') mit 'organize' genau übereinstimmen. inputString: '{addRemoveSelect} Personen, deren Antwort auf {surveyselect} ({questionSelect}) {matchSelect} "{freeTextInput}"' @@ -1851,9 +1963,9 @@ feat: add: Hinzufügen sub: Entfernen examples: - one: Personen, die an der Umfrage 'Mitgliederumfrage 2020' vor heute teilgenommen + one: Personen, die an der Umfrage 'Mitgliederbefragung 2020' vor heute teilgenommen haben. - two: Personen, die an der Umfrage 'Mitgliederumfrage 2020' in den letzten + two: Personen, die an der Umfrage 'Mitgliederumbefragung 2020' in den letzten 30 Tagen teilgenommen haben. inputString: '{addRemoveSelect} Personen, die Antworten auf {surveySelect} {timeFrame} gegeben haben.' @@ -1904,6 +2016,7 @@ feat: between: '{matchingSelect} {minInput} und {maxInput}' max: '{matchingSelect} {maxInput} {max, plural, one {mal} other {mal}}' min: '{matchingSelect} {minInput} {min, plural, one {mal} other {mal}}' + once: '{matchingSelect}' labels: between: zwischen max: höchstens @@ -1921,6 +2034,9 @@ feat: once: mindestens 1mal misc: noOptions: Keine übereinstimmenden Schlagworte + noOptionsEmailNotSent: Die E-Mail wurde nicht gesendet. Die Links wurden der + E-Mail nach dem Versand hinzugefügt. + noOptionsInvalidEmail: Ungültige E-Mail. Wähle eine E-Mail erst aus. noOptionsLinks: Keine passenden Links operators: add: Füge hinzu @@ -1933,6 +2049,9 @@ feat: single: Suche in {value} suborgs: Suche in allen Unterorganisationen quantity: + edit: + integer: '{numInput} {quantitySelect}' + percent: '{numInput} {quantitySelect}' preview: integer: '{people} {people, plural, one {Person} other {Personen}}' percent: '{people} % der Personen' @@ -1961,7 +2080,12 @@ feat: output: nach timeFrame: edit: + afterDate: '{timeFrameSelect} {afterDateSelect}' + beforeDate: '{timeFrameSelect} {beforeDateSelect}' + beforeToday: '{timeFrameSelect}' between: '{timeFrameSelect} {afterDateSelect} und {beforeDateSelect}' + ever: '{timeFrameSelect}' + future: '{timeFrameSelect}' lastFew: '{timeFrameSelect} {daysInput} {days, plural, one {Tag} other {Tage}}' preview: afterDate: nach {afterDate} @@ -2038,7 +2162,7 @@ feat: header: Überschrift chart: header: Antworten - placeholder: Sammle Umfrageteilnahmen und starte durch! + placeholder: Sammle Teilnehmer*innen für Deine Umfrage und starte durch! subheader: Gesammelte Teilnahmen des/r letzten {days, plural, =1 {Tages} other {# Tage}} tooltip: @@ -2067,11 +2191,14 @@ feat: unlockButton: Entsperren layout: actions: + delete: Mitgliederumfrage löschen publish: Umfrage veröffentlichen unpublish: Umfrage deaktivieren + warning: '"{surveyTitle}" wird gelöscht.' stats: questions: '{numQuestions, plural, one {1 Frage} other {# Fragen}}' submissions: '{numSubmissions, plural, one {1 Teilnahme} other {# Teilnahmen}}' + unknownTitle: Unbenannte Mitgliederumfrage optionCollapse: collapse: Ausblenden more: '{numOfOptions, plural, one {Zeige 1 zusätzliche Option} other {Zeige @@ -2084,7 +2211,7 @@ feat: button: Frage erstellen title: Diese Umfrage enthält bisher keine Fragen. shareSuborgsCard: - caption: Mit dieser Funktion machst du die Umfrageergebnisse für Hauptamtliche + caption: Mit dieser Funktion machst du die Ergebnisse einer Umfrage für Administrator_innen in Unterorganisationen sichtbar. title: Mit Unterorganisationen teilen state: @@ -2096,6 +2223,7 @@ feat: anonymous: Anonym hidden: Verborgen linked: verlinkt + subtitle: '{person} {date}' submissions: anonymous: Anonym dateColumn: Datum @@ -2106,11 +2234,19 @@ feat: personRecordColumn: Befragte*r suggestedPeople: Vorgeschlagene Personen unlink: Link entfernen + surveyDialog: + add: hinzufügen + cancel: Nicht hinzufügen + description: Die Person, die du gerade hinzufügen willst, hat keine E-Mail-Adresse. + Aber die Antwort auf die Umfrage beinhaltet eine. Willst du diese Adresse + der Person hinzufügen? + title: E-Mail-Adresse hinzufügen surveyForm: accept: Ich akzeptiere die unten aufgeführten Bedingungen. error: Beim Absenden Ihrer Antworten ist ein Fehler aufgetreten. Bitte versuche es später noch einmal. policy: + link: https://www.die-linke.de/seitenfuss/datenschutz/ text: Klicke hier, um die vollständige Zetkin-Datenschutzhinweise zu lesen. required: erforderlich sign: @@ -2120,8 +2256,8 @@ feat: terms: description: Wenn Du diese Umfrage abschickst, werden die von Dir zur Verfügung gestellten Informationen in Zetkin von {organization} gespeichert und verarbeitet, - um in Übereinstimmung mit den Zetkin-Datenschutzhinweisen Aktivitäten der - Partei Die Linke zu organisieren. + um in Übereinstimmung mit unseren Datenschutzhinweisen Aktivitäten der Partei + Die Linke zu organisieren. title: Datenschutzhinweise surveyFormSubmitted: text: Deine Antworten zu „{Titel}“ wurden übermittelt. @@ -2143,22 +2279,23 @@ feat: unlinkedCard: description: Wenn eine Person an der Umfrage teilnimmt ohne sich einzuloggen, ist diese Teilnahme nicht verknüpft mit einem Profil. Erst nach der Verknüpfung - mit einem Profil kannst du nach Personen in Zetkin basierend auf ihren Umfrageantworten + mit einem Profil kannst du nach Personen in Zetkin basierend auf ihren Antworten suchen. header: Nicht verknüpfte Teilnahmen openLink: '{numUnlink, plural, one {Verknüpfe Teilnahme jetzt} other {Verknüpfe Teilnahmen jetzt}}' unlinkedWarningAlert: default: - description: '{numUnlink, plural, one {Eine Umfrageteilnahme ist nicht mit - einem Zetkinprofil verknüpft, wodurch diese bei der Suche nicht berücksichtigt - werden kann.} other {Mehrere Umfrageteilnahmen sind nicht mit einem Zetkinprofil - verknüpft, wodurch diese bei der Suche nicht berücksichtigt werden können.}}' + description: '{numUnlink, plural, one {Ein Ergebnis der Umfrage ist nicht + mit einem Personendatensatz verknüpft, wodurch dieses bei der Suche nicht + berücksichtigt werden kann.} other {Mehrere Umfrageergebnisse sind nicht + mit einem Personendatensatz verknüpft, wodurch diese bei der Suche nicht + berücksichtigt werden können.}}' header: Nicht verknüpfte Teilnahmen viewUnlinked: Nur nicht verknüpfte Teilnahmen einblenden filtered: - description: Diese Liste ist gefiltert und zeigt nur Umfrageteilnahmen an, - die nicht mit einem Profil bei Zetkin verknüpft sind. + description: Diese Liste ist gefiltert und zeigt nur Befragungsergebnisse + an, die nicht mit einem Datensatz bei Zetkin verknüpft sind. header: Nur nicht verknüpfte Teilnahmen angezeigt viewAll: Alle einblenden urlCard: @@ -2387,8 +2524,10 @@ feat: du fortfahren? menu: delete: Löschen + duplicate: Dupliziere move: Verschieben rename: Umbenennen + viewCopy: '{viewName} - kopiere' moveToFolder: verschieben zu {folder} moveToRoot: Verschieben zu alle Listen browserLayout: @@ -2428,8 +2567,7 @@ feat: danach title: Kontaktaufbau surveys: - description: Suchen, filtern und entdecken basierend auf Umfrageantworten - von Personen + description: Suchen, filtern und entdecken basierend auf Umfrageantworten. title: Umfragen utility: description: viel genutzte Spalten für verschiedene Situationen @@ -2572,6 +2710,7 @@ feat: remove_rows: Es ist ein Fehler aufgetreten beim Löschen einer oder mehreren Zeilen defaultColumnTitles: + journey_assignee: Zugewiesene Anfrage local_bool: Kontrollkästchen local_person: Personenverweis organizer_action: Markierte Anrufe @@ -2608,6 +2747,10 @@ feat: footer: addPlaceholder: Tippe, um Person zur Liste hinzuzufügen alreadyInView: Bereits in Liste + moveViewDialog: + cancel: Abbrechen + emptyFolder: Leerer Ordner + moveHere: Hierher bewegen newFolderTitle: Neuer Ordner newViewFields: title: Neue Liste @@ -2664,7 +2807,13 @@ feat: delete: Liste löschen editQuery: Bearbeite intelligente Suche makeDynamic: Umwandeln in dynamische Liste (basierend auf intelligenter Suche) - makeStatic: Umwandeln in statische Liste + makeStatic: + confirmDialogInfo: Wenn du diese Liste in eine statische Liste umwandelst, + wird die intelligente Suche entfernt und alle Personen, die diese intelligente + Suche bisher hervorgebracht hat, werden nicht mehr in dieser Liste sein. + confirmDialogSubmitLabel: Umwandeln + confirmDialogTitle: Umwandeln in eine statische Liste + label: Umwandeln in eine statische Liste jumpMenu: placeholder: Tippe, um Liste zu finden subtitle: @@ -2683,6 +2832,11 @@ glob: configure: Bearbeiten & Erstellen edit: In Bearbeitung readonly: Schreibgeschützte Ansicht + genderOptions: + f: Weiblich + m: Männlich + o: Divers + unspecified: Keine Angabe personFields: alt_phone: Weitere Telefonnummer city: Stadt @@ -2737,6 +2891,7 @@ zui: title: addToJourney: Erstelle Person und füge zu {journey} hinzu addToList: Erstelle Person und füge hinzu zu {list} + assignToCanvassAssignment: Erstelle die Person und weise sie {canvassAss} zu assignToJourney: Erstelle Person und weise sie zu {journey} hinzu caller: Erstelle eine Person und füge sie als Anrufende hinzu contact: Erstelle Person und weise sie als Kontakt zu @@ -2767,13 +2922,23 @@ zui: invisible: unsichtbar oder geplant start: Startdatum dateSpan: + multiDay: '{startDate} - {endDate}' multiDayEndsToday: '{startDate} - Heute' multiDayToday: Heute - {endDate} + singleDay: '{date}' singleDayToday: Heute + duration: + days: '{n, plural, =1 {1 Tag} other {# Tage}}' + h: '{n}h' + m: '{n}min' + ms: '{n}ms' + s: '{n}sek' editTextInPlace: tooltip: edit: Klicke, um zu bearbeiten noEmpty: Dieses Feld kann nicht leer bleiben + editableImage: + add: Klick, um ein Bild hinzuzufügen futures: errorLoading: Es ist ein Fehler aufgetreten beim Laden der Daten. header: @@ -2786,7 +2951,9 @@ zui: showMore: Mehr anzeigen... orgScopeSelect: orgPlaceholder: Wähle Organisationen aus + orgSelectionLabel: '{count} ausgewählt' scope: + all: '{org} und alle Unterorganisationen' specific: Spezifische Organisation suborgs: Nur Unterorganisationen this: Nur {org} @@ -2826,9 +2993,13 @@ zui: submitOrCancel: cancel: Abbrechen submit: Erstellen + suffixedNumber: + thousands: '{num}T' timeSpan: + multiDay: '{startDate}, {start} - {endDate}, {end}' multiDayEndsToday: '{startDate}, {start} - Heute, {end}' multiDayToday: Heute, {start} - {endDate}, {end} + singleDay: '{date}, {start} - {end}' singleDayAllDay: ganztägig heute singleDayToday: Heute, {start} - {end} timeline: @@ -2839,10 +3010,13 @@ zui: from: Von to: An expand: gesamte Zeitleiste einblenden + fileUploadErrorMessage: Es ist nicht möglich, die Notiz hinzuzufügen. Vielleicht + zu lang? filter: byType: all: Alle files: Ordner + journey: Anfrage milestones: Meilensteine notes: Notizen people: Personen @@ -2859,7 +3033,13 @@ zui: addassignee: '{actor} hat {assignee} zugewiesen' addnote: '{actor} hat eine Notiz hinzugefügt' addsubject: '{actor} hat {subject} hinzugefügt' + close: + header: '{actor} hat die Anfrage geschlossen' convert: '{actor} hat {oldLabel} zu {newLabel} umgewandelt' + create: + header: '{actor} hat eine neue Anfrage begonnen' + open: '{actor} hat die Anfrage wieder begonnen' + removeassignee: '{actor} hat {assignee} entfernt' removesubject: '{actor} hat {subject} entfernt' update: readMore: Mehr diff --git a/src/locale/en.yml b/src/locale/en.yml index 14b9d01139..1aaa442794 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -296,8 +296,6 @@ feat: create: Create project error: There was an error creating the project newCampaign: My project - createCanvassAssignment: - defaultQuestion: Did you complete the mission? createEmail: newEmail: Untitled email createSurvey: diff --git a/src/locale/nn.yml b/src/locale/nn.yml index 8123ef6b92..d38ed7bdc9 100644 --- a/src/locale/nn.yml +++ b/src/locale/nn.yml @@ -22,6 +22,7 @@ feat: callassignments: Ringeoppdrag callers: Ringere campaigns: Prosjekter + canvassassignments: Dør til dør oppdrag closed: Stengt compose: Utforming conversation: Samtale @@ -40,6 +41,7 @@ feat: organize: Organiser participants: Deltakere people: Folk + plan: Plan projects: Prosjekter questions: Spørsmål settings: Innstillinger @@ -132,8 +134,10 @@ feat: today: I dag callAssignments: actions: + delete: Slett end: Avslutt oppdrag start: Start oppdrag + warning: '"{title}" blit slettet' blocked: callBackLater: Ba om å bli oppringt senere calledTooRecently: Ringt for kort tid siden @@ -152,6 +156,7 @@ feat: remove: Fjern fra oppdrag add: alreadyAdded: Lagt til tidligere + placeholder: Begynn å skrive for å søke eller legge til en ny ringer customize: exclude: h: Utelukkede etiketter @@ -278,6 +283,7 @@ feat: createButton: createActivity: Opprett createCallAssignment: Ringeoppdrag + createCanvassAssignment: Dør til dør oppdrag createEmail: E-post createEvent: Arrangement createSurvey: Spørreundersøkelse @@ -294,6 +300,8 @@ feat: create: Nytt prosjekt error: Kunne ikke opprette prosjektet newCampaign: Mitt prosjekt + createCanvassAssignment: + defaultQuestion: Fullførte du oppdraget? createEmail: newEmail: E-post uten tittel createSurvey: @@ -336,6 +344,7 @@ feat: linkGroup: createActivity: Ny aktivitet createCallAssignment: Nytt ringeoppdrag + createCanvassAssignment: Dør til dør oppdrag createEmail: Ny e-post createEvent: Nytt arrangement createSurvey: Ny spørreundersøkelse @@ -388,10 +397,14 @@ feat: o: Annet noValue: Ingen title: Informasjon som skal slås sammen + findCandidateManually: Skriv for å finne mulige duplikater infoMessage: All aktivitet og alle etiketter fra personer som slås sammen blir bevart og synlig på den sammenslåtte personen infoTitle: Ingen informasjon vil gå tapt isDuplicateButton: Inkludér + lists: + hideManual: Skjul manuelt søk + showManual: Vis manuelt søk mergeButton: Slå sammen notDuplicateButton: Utelukk peopleNotBeingMerged: Folk som ikke blir slått sammen @@ -544,6 +557,9 @@ feat: lockButton: Lås for levering lockDescription: Lås for å gjøre klar til levering locked: Låst + missingEmailsDescription: Det {numPeople, plural, one {er en person} other {er + folk}} som mangler e-postadresse i målgruppa. Hvis du retter det opp vil de + bli inkludert i målgruppa. scheduledDescription: Denne e-posten er planlagt, hvis du vil låse opp målgruppa må du avbryte levering først. sentSubtitle: Mottakere som var tilgjengelige for levering @@ -569,10 +585,16 @@ feat: targets: defineButton: Velg målgruppe editButton: Rediger målgruppe + loading: Laster... + lockButton: Lås for levering locked: Mottakerne er låst for levering + lockedChip: Låst sentSubtitle: Du kan se smartsøket som ble brukt til å velge mottakerne av denne e-posten title: Mottakere + unlockAlert: Hvis du låser opp kan det bli færre eller flere folk i målgruppa, + basert på det dynamiske smartsøket. + unlockButton: Lås opp viewButton: Se målgruppe unsubscribePage: consent: Jeg forstår @@ -831,6 +853,10 @@ feat: tooltipContent: Arrangement uten tittel viser type som tittel type: createType: Ny "{type}" + deleteMessage: Er du sikker på at du vil slette "{eventType}"arrangementstypen + for hele organisasjonen? + deleteWarning: Er du sikker på at du vil slette "{eventType}"arrangementstypen + for hele {orgTitle}? tooltip: Klikk for å endre type uncategorized: Ukategorisert files: @@ -838,6 +864,8 @@ feat: dropToUpload: Slipp en fil her for å laste opp instructions: eller trekk og slipp selectClick: Klikk for å laste opp + image: + dimensions: '{width} x {height} piksler' libraryDialog: preview: backButton: Tilbake til biblioteket @@ -855,6 +883,44 @@ feat: label: Type options: image: Bilder + home: + activityList: + actions: + call: Begynn å ringe + canvass: Begynn å besøke adresser + signUp: Meld på + undoSignup: Meld av + emptyListMessage: Du er ikke påmeldt noen aktiviteter + eventStatus: + booked: Du har blitt registrert på denne aktiviteten og vi regner med at du + skal møte opp. Kontakt {org} hvis du må avlyse. + needed: Du trengs + signedUp: Du har meldt deg på + filters: + call: Ring + canvass: Dør til dør + event: Arrangementer + allEventsList: + emptyList: + message: Finner ingen arrangementer + removeFiltersButton: Tøm filtre + filterButtonLabels: + organizations: '{numOrgs, plural,=0 {Organisasjoner} =1 {en organisasjon} + other {# organisasjoner}}' + thisWeek: Denne uka + today: I dag + tomorrow: I morgen + defaultTitles: + callAssignment: Ringeoppdrag uten navn + canvassAssignment: Dør til dør oppdrag uten navn + event: Arrangement uten navn + noLocation: Ingen fysisk plassering + footer: + privacyPolicy: Personvernerklæring + tabs: + feed: Alle arrangementer + home: Mine aktiviteter + title: Min Zetkin import: actionButtons: back: Tilbake @@ -871,14 +937,19 @@ feat: Y, M og D, og tegnene som skiller dem. For eksempel, hvis du har datoer skrevet som 1998.03.23, så skal de beskrives med YYYY.MM.DD. customFormatLabel: Tilpasset datoformat + dateConfigDescription: Velg formatet for verdiene i kolonnen så de kan importeres + som riktige datoer dateInputLabel: Datoformat dropDownLabel: Velg format emptyPreview: Kunne ikke oversettes header: Sett opp datoformat + invalidDateFormatWarning: Det er verdier i kolonnen som ikke passer med + dette formatet, er du sikker på at du har valgt riktig format? listSubHeaders: custom: Tilpasset dates: Datoformater personNumbers: Personnummer + noCustomFormatWarning: Du har ikke valgt et egenvalgt datoformat. personNumberFormat: dk: description: Verdiene i denne kolonnen oversettes fra 10 siffers danske @@ -892,12 +963,26 @@ feat: description: Verdiene i denne kolonnen oversettes fra 10 eller 12 siffers svenske personnummer (YYMMDD-XXXX eller YYYYMMDD-XXXX) til datoer. label: Svensk personnummer + enum: + header: Tildel veriene til alternativer + none: Ingen + numberOfRows: '{numRows, plural, =1 {en rad} other {# rader}}' + value: Verdi + genders: + label: Kjønn + selectLabels: + f: Kvinne + m: Mann + o: Annet + unknown: Ukjent ids: configExplanation: Import med medlemsnummer gjør at Zetkin kan oppdatere eksisterende folk (nå og senere), heller enn å lage duplikater. externalID: Medlemsnummer externalIDExplanation: Tallene i denne kolonnen er medlemsnummer fra Hypersys (ikke Zetkin). + externalIDInfo: Ekstern ID er et nummer som kommer fra et annet system enn + Zetkin, for eksempel medlemsregisteret. header: Sett opp ID/Medlemsnummer showOrganizationSelectButton: Legg til i... wrongIDFormatWarning: Verdiene i denne kolonnen ser ikke ut som Zetkin IDer. @@ -906,10 +991,13 @@ feat: zetkinID: Zetkin ID zetkinIDExplanation: Tallene i denne kolonnen er basert på en eksport fra Zetkin. + zetkinIDInfo: Zetkin ID er et nummer knyttet til en person som finnes i + Zetkin fra før. Du finner det i data som er eksportert fra Zetkin. orgs: guess: Foreslå organisasjoner header: Velg organisasjoner organizations: Organisasjon + showOrganizationSelectButton: Knytt til status: Status tags: empty: Tomt @@ -922,6 +1010,7 @@ feat: configButton: Sett opp defaultColumnHeader: Kolonne {coloumnIndex} emptyStateMessage: Begynn med å sette opp kolonnene i filen. + externalID: Ekstern ID fileHeader: Fil finishedMappingDates: Oversetter {numValues, plural, =1 {1 verdi} other {# verdier}} fra {dateFormat, select, se {Swedish personnummer} no {Norwegian @@ -935,6 +1024,7 @@ feat: to {numMappedTo, plural, =1 {en etikett} other {# etiketter}} header: Oppsett id: ID + infoButton: Info mapValuesButton: Sett opp verdier messages: manyValuesAndEmpty: '{firstValue}, {secondValue}, {thirdValue}, {numMoreValues, @@ -946,6 +1036,7 @@ feat: {# tomme rader}}.' oneValueNoEmpty: '{firstValue}.' onlyEmpty: '{numEmpty, plural, =1 {en tom rad} other {# tomme rader}}.' + readOnlyField: '{title} (kan ikke redigeres)' threeValuesAndEmpty: '{firstValue}, {secondValue}, {thirdValue} og {numEmpty, plural, =1 {en tom rad} other {# tomme rader}}.' threeValuesNoEmpty: '{firstValue}, {secondValue} og {thirdValue}.' @@ -959,6 +1050,8 @@ feat: tags: Etiketter unfinished: date: Du må sette opp datoformat + enum: Du må tildele verdier + gender: Du må tildele verdier id: Du må sette opp ID org: Du må sette opp verdier tag: Du må sette opp verdier @@ -967,10 +1060,17 @@ feat: id: ID other: Andre zetkinHeader: Zetkin + zetkinID: Zetkin ID preview: columnHeader: + gender: Kjønn org: Organisasjon tags: Etiketter + genders: + f: Kvinne + m: Mann + o: Annet + unknown: Ukjent next: Neste noOrg: Ingen organisasjon noTags: Ingen etiketter @@ -1116,6 +1216,7 @@ feat: labels: addField: Legg til felt description: Beskrivelse + requireEmailVerification: Krev e-postverifisering title: Tittel title: Rediger skjema forms: Skjemaer @@ -1137,6 +1238,10 @@ feat: approveButton: Godkjenn form: Skjema rejectButton: Avvis + submissionVerifiedPage: + h: Takk! + info: Påmeldingen din er verifisert og tillitsvalgte i {org} vil gå gjennom + den. journeys: instance: addAssigneeButton: Legg til kontaktperson @@ -1254,6 +1359,18 @@ feat: timeline: Tidslinje title: Planer organizations: + gen3: + description: Dette er den nye (generasjon 3) nettapplikasjonen. Hvis du er kjent + med den gamle så vil du finne mange nye funksjoner og forbedret design her. + Hvis du vil kan du bruke den gamle versjonen litt til. + gen2Button: Gå til gammel versjon + title: Velkommen til nye Zetkin! + notOrganizer: + description: Denne delen av Zetkin er bare tilgjengelige for de som har en administratorrolle. + Du har ikke en slik rolle, hvis du skulle hatt det må du kontakte de som er + ansvarlige for organisasjonen din. + myPageButton: Gå til aktivistportalen + title: Du har ikke tilgang som administrator page: title: Velg organisasjon sidebar: @@ -1268,6 +1385,8 @@ feat: profile: delete: button: Fjern person + confirm: Er du sikker på at du vil slette {name} fra {org}, og alle relaterte + organisasjoner? Denne handlingen kan ikke angres. title: Slett konto warning: Dette kan ikke angres! details: @@ -1276,6 +1395,8 @@ feat: editButtonClose: Avbryt redigering av {title} editButtonLabel: Rediger detaljer editPersonHeader: Rediger {person} + ellipsisMenu: + merge: Slå sammen med... genders: f: Kvinne m: Mann @@ -1411,6 +1532,9 @@ feat: email_history: description: Hvem ble tilsendt hva, når? title: Basert på eposthistorikk + joinform: + description: Finn folk som har fyllt ut innmeldingsskjema + title: Basert på innmeldingsskjema journey_subjects: description: Finn folk som har en pågående plan eller har fullført en title: Folk som deltar i en plan @@ -1560,6 +1684,10 @@ feat: notSent: ikke blitt tilsendt opened: åpnet sent: blitt tilsendt + joinForm: + anyForm: et hvilket som helst innmeldingsskjema + form: '"{title}"' + inputString: '{addRemoveSelect} folk som fylte ut {formSelect} {timeFrame}' journey: condition: conditionSelect: @@ -1625,6 +1753,7 @@ feat: sub: Fjern edit: date: '{fieldSelect} er {timeFrame}' + enum: '{fieldSelect} er "{selectInput}"' none: Organisasjonen har ingen spesialfelter text: '{fieldSelect} {freeTextInput}' url: '{fieldSelect} {freeTextInput}' @@ -1633,6 +1762,7 @@ feat: inputString: '{addRemoveSelect} folk med {field}.' preview: date: '{fieldName} {timeFrame}' + enum: '{fieldName} er "{searchTerm}"' text: '{fieldName} {searchTerm}' url: '{fieldName} {searchTerm}' personTags: @@ -1980,11 +2110,14 @@ feat: unlockButton: Lås opp layout: actions: + delete: Slett undersøkelse publish: Samle svar unpublish: Steng + warning: '"{surveyTitle}" blir slettet' stats: questions: '{numQuestions, plural, one {ett spørsmål} other {# spørsmål}}' submissions: '{numSubmissions, plural, one {ett svar} other {# svar}}' + unknownTitle: Spørreundersøkelse uten navn optionCollapse: collapse: Skjul more: '{numOfOptions, plural, one {Vis ett alternativ til} other {Vis # flere @@ -2019,6 +2152,12 @@ feat: personRecordColumn: Innsender suggestedPeople: Foreslåtte folk unlink: Fjern kobling + surveyDialog: + add: Legg til + cancel: Ikke legg til + description: Personen du skal koble til har ikke e-postadresse, men de har oppgitt + en i undersøkelsen, vil du legge adressen til personen i Zetkin? + title: Legg til e-postadresse surveyForm: accept: Jeg aksepterer vilkårene under error: Noe gikk galt med å sende inn svarene, forsøk igjen senere. @@ -2288,8 +2427,10 @@ feat: warning: Er du sikker på at du vil slette lista? Dette kan ikke angres. menu: delete: Slett + duplicate: Duplisér move: Flytt rename: Gi nytt navn + viewCopy: '{viewName} - kopi' moveToFolder: Flytt til {folder} moveToRoot: Flytt til forsiden for lister browserLayout: @@ -2566,7 +2707,12 @@ feat: delete: Slett liste editQuery: Rediger smartsøk makeDynamic: Konverter til smarsøkliste - makeStatic: Konverter til statisk liste + makeStatic: + confirmDialogInfo: Hvis du konverterer denne lista til en statisk liste + blir smartsøket fjernet og folkene søket la til forsvinner fra lista. + confirmDialogSubmitLabel: Konverter + confirmDialogTitle: Konverter til statisk liste + label: Konverter til statisk liste jumpMenu: placeholder: Skriv for å finne liste subtitle: @@ -2584,6 +2730,11 @@ glob: configure: Rediger og sett opp edit: Redigering readonly: Skrivebeskyttet + genderOptions: + f: Kvinne + m: Mann + o: Annet + unspecified: Uspesifisert personFields: alt_phone: Alternativt telefonnummer city: Poststed @@ -2622,6 +2773,8 @@ zui: cancel: Avbryt createBtn: Opprett defaultitle: Opprett person + enumFields: + noneOption: Ingen genders: f: Kvinne m: Mann @@ -2670,6 +2823,10 @@ zui: multiDayEndsToday: '{startDate} - i dag' multiDayToday: I dag - {endDate} singleDayToday: I dag + duration: + days: '{n, plural, =1 {en dag} other {# dager}}' + h: '{n}t' + ms: '{n}m' editTextInPlace: tooltip: edit: Klikk for å redigere diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/geography/index.tsx similarity index 60% rename from src/pages/organize/[orgId]/areas/index.tsx rename to src/pages/organize/[orgId]/geography/index.tsx index 727efc1c21..12c63df2e2 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/geography/index.tsx @@ -9,6 +9,8 @@ import useAreas from 'features/areas/hooks/useAreas'; import { useNumericRouteParams } from 'core/hooks'; import ZUIFuture from 'zui/ZUIFuture'; import { AREAS } from 'utils/featureFlags'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; const scaffoldOptions = { authLevelRequired: 2, @@ -21,33 +23,38 @@ export const getServerSideProps: GetServerSideProps = scaffold(async () => { }; }, scaffoldOptions); -const AreasMap = dynamic( - () => import('../../../../features/areas/components/AreasMap/index'), +const GeographyMap = dynamic( + () => import('../../../../features/geography/components/GeographyMap/index'), { ssr: false } ); -const AreasPage: PageWithLayout = () => { +const GeographyPage: PageWithLayout = () => { const { orgId } = useNumericRouteParams(); + const messages = useMessages(messageIds); const areasFuture = useAreas(orgId); return ( <> - Areas + {messages.page.title()} - {(areas) => } + {(areas) => } ); }; -AreasPage.getLayout = function getLayout(page) { +GeographyPage.getLayout = function getLayout(page) { return ( - + } + > {page} ); }; -export default AreasPage; +export default GeographyPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx index e74f6f7a48..cd365d7710 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx @@ -49,7 +49,7 @@ const CampaignActivitiesPage: PageWithLayout< const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, - ACTIVITIES.CANVASS_ASSIGNMENT, + ACTIVITIES.AREA_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, diff --git a/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx index 5981a34742..340953224c 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx @@ -37,7 +37,7 @@ const CampaignArchivePage: PageWithLayout = () => { const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, - ACTIVITIES.CANVASS_ASSIGNMENT, + ACTIVITIES.AREA_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, diff --git a/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/assignees.tsx b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/assignees.tsx new file mode 100644 index 0000000000..998ea5fe3c --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/assignees.tsx @@ -0,0 +1,108 @@ +import { Card } from '@mui/material'; +import { GetServerSideProps } from 'next'; +import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; +import Head from 'next/head'; + +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import ZUIAvatar from 'zui/ZUIAvatar'; +import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; +import { AREAS } from 'utils/featureFlags'; +import { AreaAssigneeInfo } from 'features/areaAssignments/types'; +import useAreaAssignmentSessions from 'features/areaAssignments/hooks/useAreaAssignmentSessions'; +import AreaAssignmentLayout from 'features/areaAssignments/layouts/AreaAssignmentLayout'; +import getAreaAssignees from 'features/areaAssignments/utils/getAreaAssignees'; +import useAreaAssignment from 'features/areaAssignments/hooks/useAreaAssignment'; +import { useMessages } from 'core/i18n'; +import messageIds from 'features/areaAssignments/l10n/messageIds'; + +const scaffoldOptions = { + authLevelRequired: 2, + featuresRequired: [AREAS], +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, areaAssId } = ctx.params!; + return { + props: { areaAssId, campId, orgId }, + }; +}, scaffoldOptions); + +type Props = { + areaAssId: string; + orgId: string; +}; + +const AreaAssignmentPage: PageWithLayout = ({ orgId, areaAssId }) => { + const messages = useMessages(messageIds); + const allSessions = + useAreaAssignmentSessions(parseInt(orgId), areaAssId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === areaAssId + ); + const areaAssignmentFuture = useAreaAssignment(parseInt(orgId), areaAssId); + const areaAssignees = getAreaAssignees(sessions); + + const columns: GridColDef[] = [ + { + disableColumnMenu: true, + field: 'id', + headerName: ' ', + renderCell: (params) => ( + + + + ), + sortable: false, + }, + { + field: 'name', + flex: 1, + headerName: messages.assignees.columns.name(), + valueGetter: (params) => + `${params.row.person.first_name} ${params.row.person.last_name}`, + }, + { + align: 'left', + field: 'areas', + flex: 1, + headerAlign: 'left', + headerName: messages.assignees.columns.areas(), + type: 'number', + valueGetter: (params) => params.row.sessions.length, + }, + ]; + + return ( + <> + + {areaAssignmentFuture.data?.title} + + + + + + ); +}; + +AreaAssignmentPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default AreaAssignmentPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/index.tsx new file mode 100644 index 0000000000..feb2f4c053 --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/index.tsx @@ -0,0 +1,213 @@ +import { Box, Button, Card, Divider, Grid, Typography } from '@mui/material'; +import { GetServerSideProps } from 'next'; +import { Edit } from '@mui/icons-material'; +import { useRouter } from 'next/router'; +import Head from 'next/head'; + +import AreaCard from 'features/areaAssignments/components/AreaCard'; +import { AREAS } from 'utils/featureFlags'; +import AreaAssignmentLayout from 'features/areaAssignments/layouts/AreaAssignmentLayout'; +import { PageWithLayout } from 'utils/types'; +import NumberCard from 'features/areaAssignments/components/NumberCard'; +import { scaffold } from 'utils/next'; +import useAreaAssignment from 'features/areaAssignments/hooks/useAreaAssignment'; +import useAreaAssignmentStats from 'features/areaAssignments/hooks/useAreaAssignmentStats'; +import ZUIFutures from 'zui/ZUIFutures'; +import useAssignmentAreaStats from 'features/areaAssignments/hooks/useAssignmentAreaStats'; +import useAssignmentAreaGraph from 'features/areaAssignments/hooks/useAssignmentAreaGraph'; +import { ZetkinAssignmentAreaStatsItem } from 'features/areaAssignments/types'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/areaAssignments/l10n/messageIds'; + +const scaffoldOptions = { + authLevelRequired: 2, + featuresRequired: [AREAS], +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, areaAssId } = ctx.params!; + return { + props: { areaAssId, campId, orgId }, + }; +}, scaffoldOptions); + +interface AreaAssignmentPageProps { + orgId: string; + areaAssId: string; +} + +const AreaAssignmentPage: PageWithLayout = ({ + orgId, + areaAssId, +}) => { + const messages = useMessages(messageIds); + const assignmentFuture = useAreaAssignment(parseInt(orgId), areaAssId); + const statsFuture = useAreaAssignmentStats(parseInt(orgId), areaAssId); + const areasStats = useAssignmentAreaStats(parseInt(orgId), areaAssId); + const dataGraph = useAssignmentAreaGraph(parseInt(orgId), areaAssId); + const router = useRouter(); + + return ( + <> + + {assignmentFuture.data?.title} + + + {({ data: { assignment, stats } }) => { + const planUrl = `/organize/${orgId}/projects/${ + assignment.campaign.id || 'standalone' + }/areaassignments/${assignment.id}/plan`; + return ( + + {stats.num_areas == 0 && ( + + + + + + + + + + + )} + {stats.num_areas > 0 && ( + <> + + + + + + + + + + + + + + + + + + {({ data: { areasStats, dataGraph } }) => { + const filteredAreas = dataGraph + .map((area) => { + return areasStats.stats.filter( + (item) => item.areaId === area.area.id + ); + }) + .flat(); + + const sortedAreas = filteredAreas + .map((area) => { + const successfulVisitsTotal = + dataGraph + .find((graph) => graph.area.id === area.areaId) + ?.data.reduce( + (sum, item) => sum + item.successfulVisits, + 0 + ) || 0; + + return { + area, + successfulVisitsTotal, + }; + }) + .sort( + (a, b) => + b.successfulVisitsTotal - a.successfulVisitsTotal + ) + .map(({ area }) => area); + + const maxHouseholdVisits = Math.max( + ...dataGraph.flatMap((areaCard) => + areaCard.data.map( + (graphData) => graphData.householdVisits + ) + ) + ); + + const noAreaData = dataGraph.find( + (graph) => graph.area.id === 'noArea' + ); + if (noAreaData && noAreaData.data.length > 0) { + const latestEntry = [...noAreaData.data].sort( + (a, b) => + new Date(b.date).getTime() - + new Date(a.date).getTime() + )[0]; + + const num_successful_visited_households = + latestEntry.successfulVisits; + + const num_visited_households = + latestEntry.householdVisits; + + const noArea: ZetkinAssignmentAreaStatsItem = { + areaId: 'noArea', + num_households: 0, + num_locations: 0, + num_successful_visited_households, + num_visited_households, + num_visited_locations: 0, + }; + sortedAreas.push(noArea); + } + return ( + + ); + }} + + + + )} + + ); + }} + + + ); +}; + +AreaAssignmentPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default AreaAssignmentPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/instructions.tsx similarity index 64% rename from src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx rename to src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/instructions.tsx index 92cbba7c1b..f007be2366 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/instructions.tsx @@ -3,12 +3,14 @@ import { useContext, useState } from 'react'; import { Box, Button, Link, Paper, Typography } from '@mui/material'; import { AREAS } from 'utils/featureFlags'; -import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; import { PageWithLayout } from 'utils/types'; import { scaffold } from 'utils/next'; -import useCanvassInstructions from 'features/canvassAssignments/hooks/useCanvassInstructions'; import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; import ZUITextEditor from 'zui/ZUITextEditor'; +import AreaAssignmentLayout from 'features/areaAssignments/layouts/AreaAssignmentLayout'; +import useAreaAssignmentInstructions from 'features/areaAssignments/hooks/useCanvassInstructions'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from 'features/areaAssignments/l10n/messageIds'; const scaffoldOptions = { authLevelRequired: 2, @@ -16,24 +18,23 @@ const scaffoldOptions = { }; export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { - const { orgId, campId, canvassAssId } = ctx.params!; + const { orgId, campId, areaAssId } = ctx.params!; return { - props: { campId, canvassAssId, orgId }, + props: { areaAssId, campId, orgId }, }; }, scaffoldOptions); -interface CanvassAssignmentInstructionsProps { +interface AreaAssignmentInstructionsProps { orgId: string; - canvassAssId: string; + areaAssId: string; } -const CanvassAssignmentInstructionsPage: PageWithLayout< - CanvassAssignmentInstructionsProps -> = ({ orgId, canvassAssId }) => { +const AreaAssignmentInstructionsPage: PageWithLayout< + AreaAssignmentInstructionsProps +> = ({ orgId, areaAssId }) => { + const messages = useMessages(messageIds); const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); - // const messages = useMessages(messageIds); - const { hasNewText, instructions, @@ -43,7 +44,7 @@ const CanvassAssignmentInstructionsPage: PageWithLayout< revert, save, setInstructions, - } = useCanvassInstructions(parseInt(orgId), canvassAssId); + } = useAreaAssignmentInstructions(parseInt(orgId), areaAssId); const [key, setKey] = useState(1); return ( @@ -63,7 +64,9 @@ const CanvassAssignmentInstructionsPage: PageWithLayout< padding: 2, }} > - Canvass Instructions + + + { evt.preventDefault(); @@ -84,17 +87,19 @@ const CanvassAssignmentInstructionsPage: PageWithLayout< key={key} initialValue={instructions} onChange={(markdown) => setInstructions(markdown)} - placeholder={'Add instructions for your canvassers'} + placeholder={messages.instructions.editor.editorPlaceholder()} /> {isSaved && ( - {'Everything is up to date!'} + + + )} {isUnsaved && ( - {'You have unsaved changes. '} + current + 1); }, - warningText: - 'Do you want to delete all unsaved changes and go back to saved instructions?', + warningText: messages.instructions.editor.confirm(), }); }} style={{ cursor: 'pointer', fontFamily: 'inherit' }} > - {'Revert to saved version?'} + )} @@ -122,7 +126,13 @@ const CanvassAssignmentInstructionsPage: PageWithLayout< type="submit" variant="contained" > - {isSaving ? 'Saving..' : 'Save'} + @@ -132,10 +142,8 @@ const CanvassAssignmentInstructionsPage: PageWithLayout< ); }; -CanvassAssignmentInstructionsPage.getLayout = function getLayout(page) { - return ( - {page} - ); +AreaAssignmentInstructionsPage.getLayout = function getLayout(page) { + return {page}; }; -export default CanvassAssignmentInstructionsPage; +export default AreaAssignmentInstructionsPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/map.tsx b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/map.tsx new file mode 100644 index 0000000000..ce352b1ecc --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/map.tsx @@ -0,0 +1,109 @@ +import 'leaflet/dist/leaflet.css'; +import { Box } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { GetServerSideProps } from 'next'; +import Head from 'next/head'; + +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import AreaAssignmentLayout from 'features/areaAssignments/layouts/AreaAssignmentLayout'; +import useAreas from 'features/areas/hooks/useAreas'; +import useServerSide from 'core/useServerSide'; +import useAreaAssignmentSessions from 'features/areaAssignments/hooks/useAreaAssignmentSessions'; +import useCreateAreaAssignmentSession from 'features/areaAssignments/hooks/useCreateAreaAssigneeSession'; +import { AREAS } from 'utils/featureFlags'; +import useLocations from 'features/areaAssignments/hooks/useLocations'; +import useAssignmentAreaStats from 'features/areaAssignments/hooks/useAssignmentAreaStats'; +import ZUIFutures from 'zui/ZUIFutures'; +import useAreaAssignment from 'features/areaAssignments/hooks/useAreaAssignment'; +import AreaFilterProvider from 'features/areas/components/AreaFilters/AreaFilterContext'; +import AssigneeFilterProvider from 'features/areaAssignments/components/OrganizerMapFilters/AssigneeFilterContext'; + +const OrganizerMap = dynamic( + () => + import( + '../../../../../../../features/areaAssignments/components/OrganizerMap' + ), + { ssr: false } +); + +const scaffoldOptions = { + authLevelRequired: 2, + featuresRequired: [AREAS], +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, areaAssId } = ctx.params!; + return { + props: { areaAssId, campId, orgId }, + }; +}, scaffoldOptions); + +interface OrganizerMapPageProps { + orgId: string; + areaAssId: string; +} + +const OrganizerMapPage: PageWithLayout = ({ + areaAssId, + orgId, +}) => { + const areas = useAreas(parseInt(orgId)).data || []; + const locations = useLocations(parseInt(orgId)).data || []; + const areaStatsFuture = useAssignmentAreaStats(parseInt(orgId), areaAssId); + const sessionsFuture = useAreaAssignmentSessions(parseInt(orgId), areaAssId); + const createAreaAssignmentSession = useCreateAreaAssignmentSession( + parseInt(orgId), + areaAssId + ); + const assignmentFuture = useAreaAssignment(parseInt(orgId), areaAssId); + + const isServer = useServerSide(); + if (isServer) { + return null; + } + + return ( + <> + + {assignmentFuture.data?.title} + + + + {({ data: { areaStats, assignment, sessions } }) => ( + + + { + createAreaAssignmentSession({ + areaId: area.id, + personId: person.id, + }); + }} + sessions={sessions} + /> + + + )} + + + + ); +}; + +OrganizerMapPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default OrganizerMapPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/report.tsx b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/report.tsx new file mode 100644 index 0000000000..814968fdb4 --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/areaassignments/[areaAssId]/report.tsx @@ -0,0 +1,314 @@ +import { Close } from '@mui/icons-material'; +import { GetServerSideProps } from 'next'; +import { useState } from 'react'; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Dialog, + IconButton, + MenuItem, + Select, + Typography, +} from '@mui/material'; +import Head from 'next/head'; + +import ZUIFuture from 'zui/ZUIFuture'; +import MetricCard from 'features/areaAssignments/components/MetricCard'; +import { AREAS } from 'utils/featureFlags'; +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import useAreaAssignmentMutations from 'features/areaAssignments/hooks/useAreaAssignmentMutations'; +import useAreaAssignment from 'features/areaAssignments/hooks/useAreaAssignment'; +import { + ZetkinAreaAssignment, + ZetkinMetric, +} from 'features/areaAssignments/types'; +import AreaAssignmentLayout from 'features/areaAssignments/layouts/AreaAssignmentLayout'; +import ZUICard from 'zui/ZUICard'; + +const scaffoldOptions = { + authLevelRequired: 2, + featuresRequired: [AREAS], +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, areaAssId } = ctx.params!; + return { + props: { areaAssId, campId, orgId }, + }; +}, scaffoldOptions); + +interface AreaAssignmentReportProps { + orgId: string; + areaAssId: string; +} + +const AreaAssignmentReportPage: PageWithLayout = ({ + orgId, + areaAssId, +}) => { + const { updateAreaAssignment } = useAreaAssignmentMutations( + parseInt(orgId), + areaAssId + ); + const areaAssignmentFuture = useAreaAssignment(parseInt(orgId), areaAssId); + + const [metricBeingEdited, setMetricBeingEdited] = + useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [idOfMetricBeingDeleted, setIdOfQuestionBeingDeleted] = useState< + string | null + >(null); + + const handleSaveMetric = async (metric: ZetkinMetric) => { + if (areaAssignmentFuture.data) { + await updateAreaAssignment({ + metrics: areaAssignmentFuture.data.metrics + .map((m) => (m.id === metric.id ? metric : m)) + .concat(metric.id ? [] : [metric]), + }); + } + setMetricBeingEdited(null); + }; + + const handleDeleteMetric = async (id: string) => { + if (areaAssignmentFuture.data) { + await updateAreaAssignment({ + metrics: areaAssignmentFuture.data.metrics.filter((m) => m.id !== id), + }); + } + setMetricBeingEdited(null); + }; + + const handleAddNewMetric = (kind: 'boolean' | 'scale5') => { + setMetricBeingEdited({ + definesDone: false, + description: '', + id: '', + kind: kind, + question: '', + }); + }; + + return ( + <> + + {areaAssignmentFuture.data?.title} + + + + {(assignment) => ( + <> + + + Decide what level of precision should be used for statistics. + + + + + Here you can configure the questions for your area 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} + + + ))} + + + + + )} + + + + ); +}; + +AreaAssignmentReportPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default AreaAssignmentReportPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx deleted file mode 100644 index 35b279c7ad..0000000000 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Card } from '@mui/material'; -import { GetServerSideProps } from 'next'; -import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; - -import { scaffold } from 'utils/next'; -import { PageWithLayout } from 'utils/types'; -import ZUIAvatar from 'zui/ZUIAvatar'; -import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; -import { AREAS } from 'utils/featureFlags'; -import { CanvasserInfo } from 'features/canvassAssignments/types'; -import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; -import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; -import getCanvassers from 'features/canvassAssignments/utils/getCanvassers'; - -const scaffoldOptions = { - authLevelRequired: 2, - featuresRequired: [AREAS], -}; - -export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { - const { orgId, campId, canvassAssId } = ctx.params!; - return { - props: { campId, canvassAssId, orgId }, - }; -}, scaffoldOptions); - -type Props = { - canvassAssId: string; - orgId: string; -}; - -const CanvassAssignmentPage: PageWithLayout = ({ - orgId, - canvassAssId, -}) => { - const allSessions = - useCanvassSessions(parseInt(orgId), canvassAssId).data || []; - const sessions = allSessions.filter( - (session) => session.assignment.id === canvassAssId - ); - - const canvassers = getCanvassers(sessions); - - const columns: GridColDef[] = [ - { - disableColumnMenu: true, - field: 'id', - headerName: ' ', - renderCell: (params) => ( - - - - ), - sortable: false, - }, - { - field: 'name', - flex: 1, - headerName: 'Name', - valueGetter: (params) => - `${params.row.person.first_name} ${params.row.person.last_name}`, - }, - { - align: 'left', - field: 'areas', - flex: 1, - headerAlign: 'left', - headerName: 'Areas', - type: 'number', - valueGetter: (params) => params.row.sessions.length, - }, - ]; - - return ( - - - - ); -}; - -CanvassAssignmentPage.getLayout = function getLayout(page) { - return ( - {page} - ); -}; - -export default CanvassAssignmentPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx deleted file mode 100644 index 91775c4736..0000000000 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { Box, Button, Card, Divider, Grid, Typography } from '@mui/material'; -import { GetServerSideProps } from 'next'; -import { Edit } from '@mui/icons-material'; -import { useRouter } from 'next/router'; - -import AreaCard from 'features/canvassAssignments/components/AreaCard'; -import { AREAS } from 'utils/featureFlags'; -import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; -import { PageWithLayout } from 'utils/types'; -import NumberCard from 'features/canvassAssignments/components/NumberCard'; -import { scaffold } from 'utils/next'; -import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; -import useCanvassAssignmentStats from 'features/canvassAssignments/hooks/useCanvassAssignmentStats'; -import ZUIFutures from 'zui/ZUIFutures'; -import useAssignmentAreaStats from 'features/canvassAssignments/hooks/useAssignmentAreaStats'; -import useAssignmentAreaGraph from 'features/canvassAssignments/hooks/useAssignmentAreaGraph'; -import { ZetkinAssignmentAreaStatsItem } from 'features/canvassAssignments/types'; - -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 CanvassAssignmentPageProps { - orgId: string; - canvassAssId: string; -} - -const CanvassAssignmentPage: PageWithLayout = ({ - orgId, - canvassAssId, -}) => { - const assignmentFuture = useCanvassAssignment(parseInt(orgId), canvassAssId); - const statsFuture = useCanvassAssignmentStats(parseInt(orgId), canvassAssId); - const areasStats = useAssignmentAreaStats(parseInt(orgId), canvassAssId); - const dataGraph = useAssignmentAreaGraph(parseInt(orgId), canvassAssId); - const router = useRouter(); - - return ( - - {({ data: { assignment, stats } }) => { - const planUrl = `/organize/${orgId}/projects/${ - assignment.campaign.id || 'standalone' - }/canvassassignments/${assignment.id}/plan`; - return ( - - {stats.num_areas == 0 && ( - - - - This assignment has not been planned yet. - - - - - - - )} - {stats.num_areas > 0 && ( - <> - - - Progress - - - - - - - - - - - - - - {({ data: { areasStats, dataGraph } }) => { - const filteredAreas = dataGraph - .map((area) => { - return areasStats.stats.filter( - (item) => item.areaId === area.area.id - ); - }) - .flat(); - - const sortedAreas = filteredAreas - .map((area) => { - const successfulVisitsTotal = - dataGraph - .find((graph) => graph.area.id === area.areaId) - ?.data.reduce( - (sum, item) => sum + item.successfulVisits, - 0 - ) || 0; - - return { - area, - successfulVisitsTotal, - }; - }) - .sort( - (a, b) => - b.successfulVisitsTotal - a.successfulVisitsTotal - ) - .map(({ area }) => area); - - const maxHouseholdVisits = Math.max( - ...dataGraph.flatMap((areaCard) => - areaCard.data.map( - (graphData) => graphData.householdVisits - ) - ) - ); - - const noAreaData = dataGraph.find( - (graph) => graph.area.id === 'noArea' - ); - if (noAreaData && noAreaData.data.length > 0) { - const latestEntry = [...noAreaData.data].sort( - (a, b) => - new Date(b.date).getTime() - - new Date(a.date).getTime() - )[0]; - - const num_successful_visited_households = - latestEntry.successfulVisits; - - const num_visited_households = - latestEntry.householdVisits; - - const noArea: ZetkinAssignmentAreaStatsItem = { - areaId: 'noArea', - num_households: 0, - num_places: 0, - num_successful_visited_households, - num_visited_households, - num_visited_places: 0, - }; - sortedAreas.push(noArea); - } - return ( - - ); - }} - - - - )} - - ); - }} - - ); -}; - -CanvassAssignmentPage.getLayout = function getLayout(page) { - return ( - {page} - ); -}; - -export default CanvassAssignmentPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/map.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/map.tsx deleted file mode 100644 index 60d82b7fce..0000000000 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/map.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import 'leaflet/dist/leaflet.css'; -import { Box } from '@mui/material'; -import dynamic from 'next/dynamic'; -import { GetServerSideProps } from 'next'; - -import { scaffold } from 'utils/next'; -import { PageWithLayout } from 'utils/types'; -import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; -import useAreas from 'features/areas/hooks/useAreas'; -import useServerSide from 'core/useServerSide'; -import useCanvassSessions from 'features/canvassAssignments/hooks/useCanvassSessions'; -import useCreateCanvassSession from 'features/canvassAssignments/hooks/useCreateCanvassSession'; -import { AREAS } from 'utils/featureFlags'; -import usePlaces from 'features/canvassAssignments/hooks/usePlaces'; -import useAssignmentAreaStats from 'features/canvassAssignments/hooks/useAssignmentAreaStats'; -import ZUIFutures from 'zui/ZUIFutures'; -import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; -import AreaFilterProvider from 'features/areas/components/AreaFilters/AreaFilterContext'; -import AssigneeFilterProvider from 'features/canvassAssignments/components/OrganizerMapFilters/AssigneeFilterContext'; - -const OrganizerMap = dynamic( - () => - import( - '../../../../../../../features/canvassAssignments/components/OrganizerMap' - ), - { ssr: false } -); - -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 PlanPageProps { - orgId: string; - canvassAssId: string; -} - -const PlanPage: PageWithLayout = ({ canvassAssId, orgId }) => { - const areas = useAreas(parseInt(orgId)).data || []; - const places = usePlaces(parseInt(orgId)).data || []; - const areaStatsFuture = useAssignmentAreaStats(parseInt(orgId), canvassAssId); - const sessionsFuture = useCanvassSessions(parseInt(orgId), canvassAssId); - const createCanvassSession = useCreateCanvassSession( - parseInt(orgId), - canvassAssId - ); - const assignmentFuture = useCanvassAssignment(parseInt(orgId), canvassAssId); - - const isServer = useServerSide(); - if (isServer) { - return null; - } - - return ( - - - {({ data: { areaStats, assignment, sessions } }) => ( - - - { - createCanvassSession({ - areaId: area.id, - personId: person.id, - }); - }} - places={places} - sessions={sessions} - /> - - - )} - - - ); -}; - -PlanPage.getLayout = function getLayout(page) { - return ( - {page} - ); -}; - -export default PlanPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx deleted file mode 100644 index 22ad6c2530..0000000000 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/outcomes.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { Close } from '@mui/icons-material'; -import { GetServerSideProps } from 'next'; -import { useState } from 'react'; -import { - Box, - Button, - Card, - CardActions, - CardContent, - Dialog, - IconButton, - MenuItem, - Select, - 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 { - ZetkinCanvassAssignment, - ZetkinMetric, -} from 'features/canvassAssignments/types'; -import CanvassAssignmentLayout from 'features/canvassAssignments/layouts/CanvassAssignmentLayout'; -import ZUICard from 'zui/ZUICard'; - -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 CanvassAssignmentOutcomesProps { - orgId: string; - canvassAssId: string; -} - -const CanvassAssignmentOutcomesPage: PageWithLayout< - CanvassAssignmentOutcomesProps -> = ({ 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) => ( - <> - - - Decide what level of precision should be used for statistics. - - - - - 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} - - - ))} - - - - - )} - - - ); -}; - -CanvassAssignmentOutcomesPage.getLayout = function getLayout(page) { - return ( - {page} - ); -}; - -export default CanvassAssignmentOutcomesPage; diff --git a/src/pages/organize/[orgId]/projects/activities/index.tsx b/src/pages/organize/[orgId]/projects/activities/index.tsx index 113c341d49..5ee1104e1c 100644 --- a/src/pages/organize/[orgId]/projects/activities/index.tsx +++ b/src/pages/organize/[orgId]/projects/activities/index.tsx @@ -36,7 +36,7 @@ const CampaignActivitiesPage: PageWithLayout = () => { const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, - ACTIVITIES.CANVASS_ASSIGNMENT, + ACTIVITIES.AREA_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, diff --git a/src/pages/organize/[orgId]/projects/archive/index.tsx b/src/pages/organize/[orgId]/projects/archive/index.tsx index 338f38b5cd..ac83e01cfa 100644 --- a/src/pages/organize/[orgId]/projects/archive/index.tsx +++ b/src/pages/organize/[orgId]/projects/archive/index.tsx @@ -36,7 +36,7 @@ const ActivitiesArchivePage: PageWithLayout = () => { const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, - ACTIVITIES.CANVASS_ASSIGNMENT, + ACTIVITIES.AREA_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index a298309d6e..1001c41a48 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -3,6 +3,14 @@ import { remoteItem, remoteList } from 'utils/storeUtils'; export default function mockState(overrides?: RootState) { const emptyState: RootState = { + areaAssignments: { + areaAssignmentList: remoteList(), + areaGraphByAssignmentId: {}, + areaStatsByAssignmentId: {}, + locationList: remoteList(), + sessionsByAssignmentId: {}, + statsByAreaAssId: {}, + }, areas: { areaList: remoteList(), tagsByAreaId: {}, @@ -23,14 +31,8 @@ export default function mockState(overrides?: RootState) { campaignsByOrgId: {}, recentlyCreatedCampaign: null, }, - canvassAssignments: { - areaGraphByAssignmentId: {}, - areaStatsByAssignmentId: {}, - canvassAssignmentList: remoteList(), + canvass: { myAssignmentsWithAreasList: remoteList(), - placeList: remoteList(), - sessionsByAssignmentId: {}, - statsByCanvassAssId: {}, visitsByAssignmentId: {}, }, duplicates: { diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 5692a80446..fce03375d1 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -389,7 +389,7 @@ export interface ZetkinSurveySubmissionPatchBody respondent_id: number | null; } -export interface ZetkinCanvassAssignment { +export interface ZetkinAreaAssignment { start_data: string; end_date: string; description: string; diff --git a/src/zui/ZUIOrganizeSidebar/SidebarListItem.tsx b/src/zui/ZUIOrganizeSidebar/SidebarListItem.tsx index 96dcfcc38c..cf77b34958 100644 --- a/src/zui/ZUIOrganizeSidebar/SidebarListItem.tsx +++ b/src/zui/ZUIOrganizeSidebar/SidebarListItem.tsx @@ -16,7 +16,7 @@ export interface SidebarListItemProps { | 'people' | 'projects' | 'journeys' - | 'areas' + | 'geography' | 'search' | 'settings' | 'tags'; diff --git a/src/zui/ZUIOrganizeSidebar/index.tsx b/src/zui/ZUIOrganizeSidebar/index.tsx index 0b3be4551a..dd2d1b09f9 100644 --- a/src/zui/ZUIOrganizeSidebar/index.tsx +++ b/src/zui/ZUIOrganizeSidebar/index.tsx @@ -115,7 +115,7 @@ const ZUIOrganizeSidebar = (): JSX.Element => { { icon: , name: 'projects' }, { icon: , name: 'journeys' }, { icon: , name: 'tags' }, - { icon: , name: 'areas' }, + { icon: , name: 'geography' }, { icon: , name: 'settings' }, ] as const; @@ -286,7 +286,7 @@ const ZUIOrganizeSidebar = (): JSX.Element => { )} /> {menuItemsMap.map(({ name, icon }) => { - if (name == 'areas' && !hasAreas) { + if (name == 'geography' && !hasAreas) { return null; } diff --git a/src/zui/l10n/messageIds.ts b/src/zui/l10n/messageIds.ts index 9ec9996072..ff336a98be 100644 --- a/src/zui/l10n/messageIds.ts +++ b/src/zui/l10n/messageIds.ts @@ -50,8 +50,8 @@ export default makeMessages('zui', { 'Create person and add to {journey}' ), addToList: m<{ list: string }>('Create person and add to {list}'), - assignToCanvassAssignment: m<{ canvassAss: string }>( - 'Create person and assign to {canvassAss}' + assignToAreaAssignment: m<{ areaAss: string }>( + 'Create person and assign to {areaAss}' ), assignToJourney: m<{ journey: string }>( 'Create person and assign to {journey}' @@ -149,8 +149,8 @@ export default makeMessages('zui', { }, }, organizeSidebar: { - areas: m('Areas'), filter: m('Type to filter'), + geography: m('Geography'), home: m('Home'), journeys: m('Journeys'), people: m('People'),