diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts index 0788df68d..535ff7bc6 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areasgraph/route.ts @@ -70,6 +70,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { campaign: { id: assignmentModel.campId }, end_date: assignmentModel.end_date, id: assignmentModel._id.toString(), + instructions: assignmentModel.instructions, metrics: assignmentModel.metrics.map((m) => ({ definesDone: m.definesDone, description: m.description, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts index 74856dc98..e5e24aa3f 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/areastats/route.ts @@ -80,6 +80,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, end_date: assignmentModel.end_date, id: assignmentModel._id.toString(), + instructions: assignmentModel.instructions, metrics: assignmentModel.metrics.map((m) => ({ definesDone: m.definesDone, description: m.description, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index 9fbaa543f..62c600e67 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -38,6 +38,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { campaign: { id: canvassAssignmentModel.campId }, end_date: canvassAssignmentModel.end_date, id: canvassAssignmentModel._id.toString(), + instructions: canvassAssignmentModel.instructions, metrics: (canvassAssignmentModel.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, description: metric.description || '', @@ -68,6 +69,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); const { + instructions, metrics: newMetrics, title, start_date, @@ -130,7 +132,11 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { type UpdateFieldsType = Partial< Pick< ZetkinCanvassAssignment, - 'title' | 'start_date' | 'end_date' | 'reporting_level' + | 'title' + | 'start_date' + | 'end_date' + | 'reporting_level' + | 'instructions' > >; @@ -152,6 +158,10 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { updateFields.end_date = end_date; } + if (instructions) { + updateFields.instructions = instructions; + } + if (Object.keys(updateFields).length > 0) { await CanvassAssignmentModel.updateOne( { _id: params.canvassAssId }, @@ -171,6 +181,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { campaign: { id: model.campId }, end_date: model.end_date, id: model._id.toString(), + instructions: model.instructions, metrics: (model.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, description: metric.description || '', diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts index 3aae357f8..5515312c3 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -66,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, end_date: model.end_date, id: model._id.toString(), + instructions: model.instructions, metrics: model.metrics.map((m) => ({ definesDone: m.definesDone, description: m.description, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts index fb2a1badd..5a7290613 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts @@ -73,6 +73,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, end_date: assignmentModel.end_date, id: assignmentModel._id.toString(), + instructions: assignmentModel.instructions, metrics: assignmentModel.metrics.map((m) => ({ definesDone: m.definesDone, description: m.description, diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts index 846ef43c5..a5e19f7c4 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts @@ -29,6 +29,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, end_date: assignment.end_date, id: assignment._id.toString(), + instructions: assignment.instructions, metrics: (assignment.metrics || []).map((metric) => ({ definesDone: metric.definesDone || false, description: metric.description || '', @@ -61,6 +62,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const model = new CanvassAssignmentModel({ campId: payload.campaign_id, + instructions: payload.instructions, metrics: payload.metrics || [], orgId: orgId, reporting_level: payload.reporting_level || 'household', @@ -74,6 +76,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { campaign: { id: model.campId }, end_date: model.end_date, id: model._id.toString(), + instructions: model.instructions, metrics: model.metrics.map((metric) => ({ definesDone: metric.definesDone || false, description: metric.description || '', diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/canvassassignments/route.ts index 0ae4e8cc3..0612acb87 100644 --- a/src/app/beta/users/me/canvassassignments/route.ts +++ b/src/app/beta/users/me/canvassassignments/route.ts @@ -66,6 +66,7 @@ export async function GET(request: NextRequest) { }, end_date: assignment.end_date, id: assignment._id.toString(), + instructions: assignment.instructions, metrics: assignment.metrics.map((m) => ({ definesDone: m.definesDone, description: m.description, diff --git a/src/app/canvass/[canvassAssId]/map/page.tsx b/src/app/canvass/[canvassAssId]/map/page.tsx new file mode 100644 index 000000000..3f5dcaae2 --- /dev/null +++ b/src/app/canvass/[canvassAssId]/map/page.tsx @@ -0,0 +1,29 @@ +import 'leaflet/dist/leaflet.css'; +import { headers } from 'next/headers'; +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'; + +interface PageProps { + params: { + canvassAssId: string; + }; +} + +export default async function Page({ params }: PageProps) { + const { canvassAssId } = params; + const headersList = headers(); + const headersEntries = headersList.entries(); + const headersObject = Object.fromEntries(headersEntries); + const apiClient = new BackendApiClient(headersObject); + + try { + await apiClient.get(`/api/users/me`); + + return ; + } catch (err) { + return redirect(`/login?redirect=/canvass/${canvassAssId}/map`); + } +} diff --git a/src/app/canvass/[canvassAssId]/page.tsx b/src/app/canvass/[canvassAssId]/page.tsx index 73a078092..7a575d976 100644 --- a/src/app/canvass/[canvassAssId]/page.tsx +++ b/src/app/canvass/[canvassAssId]/page.tsx @@ -2,8 +2,8 @@ import 'leaflet/dist/leaflet.css'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import MyCanvassAssignmentPage from 'features/canvassAssignments/components/MyCanvassAssignmentPage'; import BackendApiClient from 'core/api/client/BackendApiClient'; +import MyCanvassInstructionsPage from 'features/canvassAssignments/components/MyCanvassInstructionsPage'; import { ZetkinOrganization } from 'utils/types/zetkin'; interface PageProps { @@ -22,7 +22,7 @@ export default async function Page({ params }: PageProps) { try { await apiClient.get(`/api/users/me`); - return ; + return ; } catch (err) { return redirect(`/login?redirect=/canvass/${canvassAssId}`); } diff --git a/src/app/o/[orgId]/joinformverified/page.tsx b/src/app/o/[orgId]/joinformverified/page.tsx new file mode 100644 index 000000000..bb316c1be --- /dev/null +++ b/src/app/o/[orgId]/joinformverified/page.tsx @@ -0,0 +1,29 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; +import { ZetkinOrganization } from 'utils/types/zetkin'; +import JoinFormVerifiedPage from 'features/joinForms/components/JoinFormVerifiedPage'; + +type PageProps = { + params: { + orgId: string; + }; +}; + +export default async function Page({ params }: PageProps) { + const headersList = headers(); + const headersEntries = headersList.entries(); + const headersObject = Object.fromEntries(headersEntries); + const apiClient = new BackendApiClient(headersObject); + + try { + const org = await apiClient.get( + `/api/orgs/${params.orgId}` + ); + + return ; + } catch (err) { + return notFound(); + } +} diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index ff8d0b9e9..a1ced9b8e 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -126,6 +126,7 @@ const CampaignActionButtons: React.FunctionComponent< onClick: () => createCanvassAssignment({ campaign_id: campaign.id, + instructions: '', metrics: [ { definesDone: true, diff --git a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx b/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx index cfda27e84..196bbb6b9 100644 --- a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx +++ b/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx @@ -5,6 +5,7 @@ import { CircularProgress, Divider, List, + ListItem, ListItemButton, ListItemText, Typography, @@ -12,6 +13,7 @@ import { import { ZetkinCanvassAssignment } from '../../types'; import useSidebarStats from 'features/canvassAssignments/hooks/useSidebarStats'; +import ZUIMarkdown from 'zui/ZUIMarkdown'; import ZUIRelativeTime from 'zui/ZUIRelativeTime'; type Props = { @@ -108,6 +110,16 @@ const CanvasserSidebar: FC = ({ assignment }) => { + {assignment.instructions && ( + + + ({ bgcolor: theme.palette.grey[100] })} /> + + + + ({ bgcolor: theme.palette.grey[100] })} /> + + )} diff --git a/src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx b/src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx new file mode 100644 index 000000000..a1a4aceb2 --- /dev/null +++ b/src/features/canvassAssignments/components/MyCanvassInstructionsPage.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { FC } from 'react'; +import { HomeWork } from '@mui/icons-material'; +import { + Avatar, + Box, + Button, + Card, + CircularProgress, + Divider, + Typography, +} from '@mui/material'; +import { useRouter } from 'next/navigation'; + +import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; +import { ZetkinCanvassAssignment } from '../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'; + +const InstructionsPage: FC<{ + assignment: ZetkinCanvassAssignment; +}> = ({ assignment }) => { + const orgFuture = useOrganization(assignment.organization.id); + const router = useRouter(); + const { loading, stats } = useSidebarStats( + assignment.organization.id, + assignment.id + ); + + const { data } = useCanvassAssignmentStats( + assignment.organization.id, + assignment.id + ); + + const allSessions = + useCanvassSessions(assignment.organization.id, assignment.id).data || []; + + const membershipFuture = useMembership(assignment.organization.id); + const userPersonId = membershipFuture.data?.profile.id; + const sessions = allSessions.filter( + (session) => + session.assignment.id === assignment.id && + session.assignee.id === userPersonId + ); + + const areaIds = sessions.map((entry) => entry.area.id); + const numberOfAreas = areaIds.length; + + return ( + + {({ data: { org } }) => ( + + + + + {assignment.title ?? 'Untitled canvassassignment'} + + + + {org.title} + + + + + + {loading ? ( + + + + ) : ( + + + + Areas + + + + {numberOfAreas} + + + + + + + + {stats.allTime.numHouseholds} + + + {' / '} + + + {data?.num_households} + + + + Households visited + + + + + + + {stats.allTime.numPlaces} + + + {' / '} + + + {data?.num_places} + + + + Places visited + + + + + )} + + {assignment.instructions ? ( + + Instructions + + + + + + ) : ( + + + + You are ready to go + + + )} + + + + + + )} + + ); +}; + +type MyCanvassInstructionsPageProps = { + canvassAssId: string; +}; + +const MyCanvassInstructionsPage: FC = ({ + canvassAssId, +}) => { + const myAssignments = useMyCanvassAssignments() || []; + const assignment = myAssignments.find( + (assignment) => assignment.id == canvassAssId + ); + + if (!assignment) { + return null; + } + + return ; +}; + +export default MyCanvassInstructionsPage; diff --git a/src/features/canvassAssignments/hooks/useCanvassInstructions.ts b/src/features/canvassAssignments/hooks/useCanvassInstructions.ts new file mode 100644 index 000000000..9afcfe97d --- /dev/null +++ b/src/features/canvassAssignments/hooks/useCanvassInstructions.ts @@ -0,0 +1,98 @@ +import { useState } from 'react'; + +import { RootState } from 'core/store'; +import { useAppSelector } from 'core/hooks'; +import useCanvassAssignmentMutations from './useCanvassAssignmentMutations'; +import useCanvassAssignment from './useCanvassAssignment'; + +export default function useCanvassInstructions( + orgId: number, + canvassAssId: string +) { + const { updateCanvassAssignment } = useCanvassAssignmentMutations( + orgId, + canvassAssId + ); + const { data: canvassAssignment } = useCanvassAssignment(orgId, canvassAssId); + const canvassAssignmentSlice = useAppSelector( + (state: RootState) => state.canvassAssignments + ); + const canvassAssignmentItems = + canvassAssignmentSlice.canvassAssignmentList.items; + const key = `callerInstructions-${canvassAssId}`; + //Used to force re-render + const [pointlessState, setPointlessState] = useState(0); + + const getInstructions = () => { + const lsInstructions = localStorage.getItem(key); + + if (lsInstructions === null) { + if (canvassAssignment) { + localStorage.setItem(key, canvassAssignment.instructions); + return canvassAssignment.instructions; + } + } + return lsInstructions || ''; + }; + + const isSaving = (): boolean => { + const item = canvassAssignmentItems.find( + (item) => item.id === canvassAssId + ); + + if (!item) { + return false; + } + + return item.mutating.includes('instructions'); + }; + + const hasUnsavedChanges = (): boolean => { + if (!canvassAssignment) { + return false; + } + + const lsInstructions = localStorage.getItem(key)?.trim() || ''; + const dataInstructions = canvassAssignment.instructions?.trim(); + + return dataInstructions != lsInstructions; + }; + + const hasEmptyInstrunctions = (): boolean => { + return canvassAssignment?.instructions === ''; + }; + + const instructions = getInstructions(); + const saving = isSaving(); + const unsavedChanges = hasUnsavedChanges(); + + return { + hasNewText: unsavedChanges, + instructions, + isSaved: !saving && !unsavedChanges && instructions !== '', + isSaving: saving, + isUnsaved: !saving && unsavedChanges && !hasEmptyInstrunctions(), + revert: () => { + if (!canvassAssignment) { + return; + } + + localStorage.setItem(key, canvassAssignment?.instructions); + //TODO: remove this ugly ass forced re-render by making ZUITextEditor better + setPointlessState(pointlessState + 1); + }, + save: () => { + const lsInstructions = localStorage.getItem(key) || ''; + const saveFuture = updateCanvassAssignment({ + instructions: lsInstructions, + }); + + return saveFuture; + }, + setInstructions: (instructions: string): void => { + localStorage.setItem(key, instructions); + //TODO: remove this ugly ass forced re-render by making ZUITextEditor better + setPointlessState(pointlessState + 1); + }, + }; +} diff --git a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx index 397714129..1a63863c3 100644 --- a/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/canvassAssignments/layouts/CanvassAssignmentLayout.tsx @@ -138,6 +138,7 @@ const CanvassAssignmentLayout: FC = ({ { href: '/map', label: 'Map' }, { href: '/canvassers', label: 'Canvassers' }, { href: '/outcomes', label: 'Outcomes' }, + { href: '/instructions', label: 'Instructions' }, ]} title={ & { _id: string })[]; orgId: number; reporting_level: 'household' | 'place' | null; @@ -26,6 +27,7 @@ const canvassAssignmentSchema = default: null, type: String, }, + instructions: String, metrics: [ { definesDone: Boolean, diff --git a/src/features/canvassAssignments/types.ts b/src/features/canvassAssignments/types.ts index 7ceed121f..2b2846ee8 100644 --- a/src/features/canvassAssignments/types.ts +++ b/src/features/canvassAssignments/types.ts @@ -21,6 +21,7 @@ export type ZetkinCanvassAssignment = { }; end_date: string | null; id: string; + instructions: string; metrics: ZetkinMetric[]; organization: { id: number; diff --git a/src/features/import/hooks/useColumn.ts b/src/features/import/hooks/useColumn.ts index 16c9302d1..da998bcc7 100644 --- a/src/features/import/hooks/useColumn.ts +++ b/src/features/import/hooks/useColumn.ts @@ -44,6 +44,10 @@ export default function useColumn(orgId: number) { return column.field == value.slice(6); } + if (column.kind == ColumnKind.GENDER) { + return column.field == value; + } + if (column.kind == ColumnKind.DATE) { return column.field == value.slice(5); } diff --git a/src/features/joinForms/components/JoinFormVerifiedPage.tsx b/src/features/joinForms/components/JoinFormVerifiedPage.tsx new file mode 100644 index 000000000..f02e664fd --- /dev/null +++ b/src/features/joinForms/components/JoinFormVerifiedPage.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { FC } from 'react'; +import { Box, Typography } from '@mui/material'; + +import messageIds from '../l10n/messageIds'; +import { Msg } from 'core/i18n'; +import { ZetkinOrganization } from 'utils/types/zetkin'; + +type Props = { + org: ZetkinOrganization; +}; + +const JoinFormVerifiedPage: FC = ({ org }) => { + return ( + + + + + + + + + + + ); +}; + +export default JoinFormVerifiedPage; diff --git a/src/features/joinForms/l10n/messageIds.ts b/src/features/joinForms/l10n/messageIds.ts index 5c384aac8..cb09b734f 100644 --- a/src/features/joinForms/l10n/messageIds.ts +++ b/src/features/joinForms/l10n/messageIds.ts @@ -40,4 +40,10 @@ export default makeMessages('feat.joinForms', { form: m('Form'), rejectButton: m('Reject'), }, + submissionVerifiedPage: { + h: m('Thank you!'), + info: m<{ org: string }>( + 'Your submission has been verified and organizers in {org} will review it shortly.' + ), + }, }); diff --git a/src/features/surveys/components/SurveyLinkDialog.tsx b/src/features/surveys/components/SurveyLinkDialog.tsx new file mode 100644 index 000000000..d9b7f76f1 --- /dev/null +++ b/src/features/surveys/components/SurveyLinkDialog.tsx @@ -0,0 +1,75 @@ +import { ArrowForward } from '@mui/icons-material'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Typography, +} from '@mui/material'; + +import messageIds from '../l10n/messageIds'; +import { useNumericRouteParams } from 'core/hooks'; +import { useMessages } from 'core/i18n'; +import usePersonMutations from 'features/profile/hooks/usePersonMutations'; +import { ZetkinPerson } from 'utils/types/zetkin'; +import ZUIPersonAvatar from 'zui/ZUIPersonAvatar'; + +const SurveyLinkDialog = ({ + email, + onClose, + open, + person, +}: { + email: string; + onClose: () => void; + open: boolean; + person: ZetkinPerson; +}) => { + const messages = useMessages(messageIds); + const { orgId } = useNumericRouteParams(); + const { updatePerson } = usePersonMutations(orgId, person.id); + + return ( + + {messages.surveyDialog.title()} + + + + {email} + + + + + + {person?.first_name} {person?.last_name} + + + {messages.surveyDialog.description()} + + + + + + + ); +}; + +export default SurveyLinkDialog; diff --git a/src/features/surveys/components/SurveySubmissionsList.tsx b/src/features/surveys/components/SurveySubmissionsList.tsx index 14a96d63a..f4d0c0780 100644 --- a/src/features/surveys/components/SurveySubmissionsList.tsx +++ b/src/features/surveys/components/SurveySubmissionsList.tsx @@ -7,9 +7,10 @@ import { GridRenderCellParams, useGridApiContext, } from '@mui/x-data-grid-pro'; -import { FC, useEffect, useMemo } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import messageIds from '../l10n/messageIds'; +import SurveyLinkDialog from './SurveyLinkDialog'; import SurveySubmissionPane from '../panes/SurveySubmissionPane'; import { useNumericRouteParams } from 'core/hooks'; import { usePanes } from 'utils/panes'; @@ -31,6 +32,9 @@ const SurveySubmissionsList = ({ const { orgId } = useRouter().query; const { openPane } = usePanes(); + const [dialogPerson, setDialogPerson] = useState(null); + const [dialogEmail, setDialogEmail] = useState(''); + const sortedSubmissions = useMemo(() => { const sorted = [...submissions].sort((subOne, subTwo) => { const dateOne = new Date(subOne.submitted); @@ -198,6 +202,15 @@ const SurveySubmissionsList = ({ id: row.id, }); setRespondentId(person?.id || null); + + const respondentEmail = row.respondent?.email; + if (person) { + const personHasNoEmail = person.email == null || person.email == ''; + if (personHasNoEmail && respondentEmail != undefined) { + setDialogEmail(respondentEmail); + setDialogPerson(person); + } + } }; return ( @@ -247,6 +260,14 @@ const SurveySubmissionsList = ({ border: 'none', }} /> + {dialogPerson && ( + setDialogPerson(null)} + open={!!dialogPerson} + person={dialogPerson} + /> + )} ); }; diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index f83e84f67..5dd6c4f0e 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -171,6 +171,14 @@ export default makeMessages('feat.surveys', { suggestedPeople: m('Suggested people'), unlink: m('Unlink'), }, + surveyDialog: { + add: m('Add'), + cancel: m("Don't add"), + description: m( + 'The person you are about to link does not have an email address while the survey response does. Would you like to add it the person?' + ), + title: m('Add email address'), + }, surveyForm: { accept: m('I accept the terms stated below'), error: m( diff --git a/src/features/surveys/store.ts b/src/features/surveys/store.ts index db0513878..73035d4fc 100644 --- a/src/features/surveys/store.ts +++ b/src/features/surveys/store.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { personUpdated } from 'features/profile/store'; import { SurveyStats } from './rpc/getSurveyStats'; import { ELEMENT_TYPE, @@ -39,6 +40,33 @@ const initialState: SurveysStoreSlice = { }; const surveysSlice = createSlice({ + extraReducers: (builder) => + builder.addCase(personUpdated, (state, action) => { + const person = action.payload; + const item = state.submissionList.items.find( + (item) => item?.data?.respondent?.id === person.id + ); + + if (item?.data?.respondent) { + const respondent = item.data.respondent; + + if (person.email) { + respondent.email = person.email; + } + if (person.first_name) { + respondent.first_name = person.first_name; + } + if (person.last_name) { + respondent.last_name = person.last_name; + } + } + const submissionsUpdated = state.submissionList.items + .map((item) => item.data) + .filter((data): data is ZetkinSurveySubmission => data !== null); + + addSubmissionToState(state, submissionsUpdated); + }), + initialState, name: 'surveys', reducers: { diff --git a/src/locale/de.yml b/src/locale/de.yml index 75e8e4618..f2a951b53 100644 --- a/src/locale/de.yml +++ b/src/locale/de.yml @@ -409,10 +409,14 @@ feat: o: Divers noValue: Kein Datenwert title: Zusammenzuführende Daten + findCandidateManually: Versuche ein potenzielles Duplikat zu finden infoMessage: Alle Aktivitäten und Tags von allen Personen, die zusammengeführt werden, werden übertragen und sichtbar sein bei der zusammengeführten Person. infoTitle: Keine Daten werden verloren gehen. isDuplicateButton: Einbeziehen + lists: + hideManual: Ausblenden der manuellen Suche + showManual: Einblenden der manuellen Suche mergeButton: Zusammenführen notDuplicateButton: Ausschließen peopleNotBeingMerged: Personen, die nicht zusammengeführt werden @@ -422,6 +426,11 @@ feat: name: Name phone: Telefon 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, + die du in den obenstehenden Feldern nicht mit übernimmst, sind dann weg. + warningTitle: Achtung, Risiko von Datenverlust. page: dismiss: ablehnen noDuplicates: keine Duplikate @@ -565,6 +574,9 @@ feat: lockButton: Sperren für den Versand lockDescription: Sperren, um den E-Mail-Versand vorzubereiten. locked: Gesperrt + missingEmailsDescription: Es gibt {numPeople, plural, one {ist eine Person} + other {sind Personen}} mit einer fehlenden E-Mail-Adresse in der Zielgruppe. + Wenn du diese fehldenden Adressen ergänzt, werden sie in der Zielgruppe hinzugefügt. scheduledDescription: Diese E-Mail ist für den Versand geplant. Wenn du die Ziele entsperren willst, musst du erst den Versand abbrechen. sentSubtitle: Ziele, die für den Versand zur Verfügung standen. @@ -589,11 +601,16 @@ feat: targets: defineButton: Definiere die Zielgruppe editButton: Bearbeite die Zielgruppe + loading: Laden... + lockButton: Sperren, um zu Versenden locked: Ziele sind für den Versand gesperrt. + lockedChip: Gesperrt sentSubtitle: Du kannst dir die Smarte Suche ansehen, die zu Grunde gelegt wurde, um die Empfänger dieser E-Mail zu bestimmen. - subtitle: Benutze die Smarte Suche, um die Empfänger dieser E-Mail zu bestimmen. title: Ziele + unlockAlert: Entsperren kann Personen von der Zielgruppe hinzufügen oder entfernen, + je nachdem welches Ergebnis die smarte Suche haben wird. + unlockButton: Entsperren viewButton: Zielgruppe ansehen unsubscribePage: consent: Ich stimme zu @@ -813,6 +830,29 @@ feat: du Erinnerungen versendest reqParticipantsHelperText: Mindestteilnehmendenzahl reqParticipantsLabel: Mindestteilnehmendenzahl + participantsModal: + affected: + empty: Du hast noch keine Veränderungen vorgenommen. Wählen eine Veranstaltung, + um die Teilnehmer_innen zu verschieben. + header: Betroffene Personen + discardButton: Änderungen verwerfen + emptyStates: + booked: Niemand wurde in diese Veranstaltung bisher eingebucht. Du kannst + Teilnehmer_innen aus deiner Datenbasis hinzufügen. + pending: Es gibt keine weiteren Teilnehmer_innen in der Datenbasis. Du kannst + Teilnehmer_innen einer Veranstaltung in diese Datenbasis hinzufügen. + participants: + buttons: + addBack: wieder hinzufügen + addHere: hier hinzufügen + move: Bewegen + undo: Rückgängig machen + headers: + booked: Diese Veranstaltung + pending: Personen können hinzugefügt werden. + states: + added: werden zu dieser Veranstaltung hinzugefügt + pending: In der Datenbasis search: Suche state: cancelled: Abgesagt @@ -852,6 +892,41 @@ feat: label: Typ options: image: Bilder + home: + activityList: + actions: + call: Beginne mit dem Anrufen + signUp: Anmelden + undoSignup: Abmelden + emptyListMessage: Du bist für keine Aktivitäten angemeldet + eventStatus: + booked: Du bist für diese Veranstaltung gebucht und die Organisator_innen + erwarten dich. Kontaktiere {org} wenn du absagen willst. + needed: Du wirst gebraucht. + signedUp: Du hast dich angemeldet. + filters: + call: Anrufen + event: Veranstaltungen + allEventsList: + emptyList: + message: Es konnten keine Veranstaltungen gefunden werden. + removeFiltersButton: Filter zurücksetzen. + filterButtonLabels: + organizations: '{numOrgs, plural,=0 {Organisationen} =1 {1 Organisation} other + {# Organisationen}}' + thisWeek: Diese Woche + today: Heute + tomorrow: Morgen + defaultTitles: + callAssignment: Unbenannte Anruf-Aufgabe + event: Unbenannte Veranstaltung + noLocation: Kein Ort. + footer: + privacyPolicy: Datenschutzhinweise + tabs: + feed: Alle Veranstaltungen + home: Meine Aktivitäten + title: Mein Zetkin import: actionButtons: back: Zurück @@ -896,6 +971,13 @@ feat: none: Keine numberOfRows: '{numRows, plural, =1 {1 Reihe} other {# Reihen}}' value: Wert + genders: + label: Geschlecht + selectLabels: + f: Weiblich + m: Männlich + o: Divers + unknown: Keine Angabe ids: configExplanation: Importieren mit IDs erlaubt Zetkin (jetzt oder in der Zukunft) existierende Personen in der Datenbank zu aktualisieren, ohne @@ -1111,6 +1193,7 @@ feat: labels: addField: Feld hinzufügen description: Beschreibung + requireEmailVerification: E-Mail-Bestätigung ist notwendig. title: Titel title: Formular bearbeiten forms: Formular @@ -1132,6 +1215,10 @@ feat: approveButton: Bestätigen form: Formulare rejectButton: Ablehnen + submissionVerifiedPage: + h: Danke + info: Deine Anfrage wurde bestätigt und ein_e Organisator_in wird sie in Kürze + bearbeiten. journeys: instance: addAssigneeButton: Beauftragte hinzufügen @@ -1246,6 +1333,8 @@ feat: overview: Übersicht timeline: Zeitleiste organizations: + gen3: + title: Willkommen im neuen Zetkin! page: title: 'Organisation auswählen:' sidebar: @@ -1401,6 +1490,10 @@ feat: email_history: description: Wem wurde was gesendet, wann? title: Auf Grundlage ihres E-Mail-Verlaufs + joinform: + description: Finde Personen, die über ein Zetkin-Beitrittsformular hinzugekommen + sind. + title: Hinzugekommen auf Grundlage der Quelle journey_subjects: description: Finde Personen, die Anfragen bearbeiten oder diese abgeschlossen haben. @@ -1492,7 +1585,10 @@ feat: für irgendeine Aufgabe zu irgendeinem Zeitpunkt. two: Entferne Personen, die in den letzten 30 Tagen mindestens 1mal angerufen wurden für die Aufgabe "langjährige Mitglieder aktivieren". + inputString: '{addRemoveSelect} Personen die {callSelect} {minTimes} in {assignmentSelect} + {timeFrame}.' minTimes: '{minTimesInput} {minTimes, plural, one {mal} other {mal}}' + minTimesInput: mindestens {input} {minTimes, plural, one {mal} other {male}} campaignParticipation: activitySelect: activity: Typ "{activity}" diff --git a/src/locale/en.yml b/src/locale/en.yml index ebfe49775..c574a894d 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -156,6 +156,7 @@ feat: remove: Remove from assignment add: alreadyAdded: Already added + placeholder: Start typing to search or add a new caller customize: exclude: h: Excluded tags @@ -431,7 +432,7 @@ feat: add one. notTargeted: Your email has no targets. Go to the Targets section in the Overview tab to create a Smart Search that defines your targets. - targetsNotLocked: The targets are not locked. Go to the Ready section in the + targetsNotLocked: The targets are not locked. Go to the Targets section in the Overview tab to do this. deliveryStatus: notLocked: Not locked, not scheduled @@ -825,7 +826,10 @@ feat: tooltipContent: Untitled events will display type as title type: createType: Create "{type}" - deleteMessage: Are you sure you want to delete the "{eventType}" event type for the whole organization? + deleteMessage: Are you sure you want to delete the "{eventType}" event type for + the whole organization? + deleteWarning: Are you sure you want to delete the "{eventType}" event type for + all of {orgTitle}? tooltip: Click to change type uncategorized: Uncategorized files: @@ -852,6 +856,44 @@ feat: label: Type options: image: Images + home: + activityList: + actions: + call: Start calling + canvass: Start canvassing + signUp: Sign up + undoSignup: Undo signup + emptyListMessage: You are not signed up for any acitvities + eventStatus: + booked: You have been booked for this event and organizers are expecting you. + Contact {org} if you need to cancel. + needed: You are needed + signedUp: You have signed up + filters: + call: Call + canvass: Canvass + event: Events + allEventsList: + emptyList: + message: Could not find any events + removeFiltersButton: Clear filters + filterButtonLabels: + organizations: "{numOrgs, plural,=0 {Organizations} =1 {1 organization} other {# + organizations}}" + thisWeek: This week + today: Today + tomorrow: Tomorrow + defaultTitles: + callAssignment: Untitled call assignment + canvassAssignment: Untitled canvass assignment + event: Untitled event + noLocation: No physical location + footer: + privacyPolicy: Privacy policy + tabs: + feed: All events + home: My activities + title: My Zetkin import: actionButtons: back: Back @@ -869,14 +911,19 @@ feat: For example, if your dates are written 1998.03.23, you would describe that as YYYY.MM.DD. customFormatLabel: Custom date format + dateConfigDescription: Select the format of the values in this column so they + can be imported as correct dates. dateInputLabel: Date format dropDownLabel: Select format emptyPreview: Could not be parsed header: Configure date format + invalidDateFormatWarning: There are values in the column that don't seem to fit + this format. Are you sure you have selected the correct format? listSubHeaders: custom: Custom dates: Date formats personNumbers: Person numbers + noCustomFormatWarning: You have not provided a custom date format. personNumberFormat: dk: description: The values in this column will be parsed from 10 digit Danish @@ -895,24 +942,29 @@ feat: none: None numberOfRows: "{numRows, plural, =1 {1 row} other {# rows}}" value: Value + genders: + label: Gender + selectLabels: + f: Female + m: Male + o: Other + unknown: Unknown ids: - configExplanation: Importing with IDs allows Zetkin (now or in the future) to - update existing people in the database instead of creating - duplicates. externalID: External ID - externalIDExplanation: The values in this column are IDs from our main member - system (not Zetkin). - header: Configure IDs - showOrganizationSelectButton: Map to... + externalIDInfo: An external ID is an ID that comes from another system than + Zetkin, such as a separate member database. It can be used to find + and identify people in Zetkin. wrongIDFormatWarning: The values in this column does not look like Zetkin IDs. A Zetkin ID only contains numbers. If some cells are empty or contain f.x. letters, it can not be used as Zetkin IDs. zetkinID: Zetkin ID - zetkinIDExplanation: The values in this column are based on an export from Zetkin. + zetkinIDInfo: A Zetkin ID is the ID of a person already in Zetkin. You would + have it in a file if you exported data from Zetkin. orgs: guess: Guess organisations header: Map values to organizations organizations: Organization + showOrganizationSelectButton: Map to... status: Status tags: empty: Empty @@ -925,6 +977,7 @@ feat: configButton: Configure defaultColumnHeader: Column {columnIndex} emptyStateMessage: Start by mapping file columns. + externalID: External ID fileHeader: File finishedMappingDates: Mapping {numValues, plural, =1 {1 value} other {# values}} from {dateFormat, select, se {Swedish personnummer} no {Norwegian @@ -937,7 +990,7 @@ feat: finishedMappingTags: Mapping {numRows, plural, =1 {1 row} other {# rows}} to {numMappedTo, plural, =1 {1 tag} other {# tags}} header: Mapping - id: ID + infoButton: Info mapValuesButton: Map values messages: manyValuesAndEmpty: "{firstValue}, {secondValue}, {thirdValue}, {numMoreValues, @@ -950,6 +1003,7 @@ feat: {# empty rows}}." oneValueNoEmpty: "{firstValue}." onlyEmpty: "{numEmpty, plural, =1 {one empty row} other {# empty rows}}." + readOnlyField: "{title} (read only)" threeValuesAndEmpty: "{firstValue}, {secondValue}, {thirdValue} and {numEmpty, plural, =1 {one empty row} other {# empty rows}}." threeValuesNoEmpty: "{firstValue}, {secondValue} and {thirdValue}." @@ -962,6 +1016,7 @@ feat: unfinished: date: You need to configure date format enum: You need to map values + gender: You need to map values id: You need to configure the IDs org: You need to map values tag: You need to map values @@ -970,10 +1025,17 @@ feat: id: ID other: Other zetkinHeader: Zetkin + zetkinID: Zetkin ID preview: columnHeader: + gender: Gender org: Organization tags: Tags + genders: + f: Female + m: Male + o: Other + unknown: Unknown next: Next noOrg: No organization noTags: No tags @@ -1141,6 +1203,10 @@ feat: approveButton: Approve form: Form rejectButton: Reject + submissionVerifiedPage: + h: Thank you! + info: Your submission has been verified and organizers in {org} will review it + shortly. journeys: instance: addAssigneeButton: Add assignee @@ -1287,6 +1353,8 @@ feat: profile: delete: button: Remove person + confirm: Are you sure you want to delete {name} from {org}, and all related + organizations? This is a permanent action. title: Delete account warning: This cannot be undone! details: @@ -1424,6 +1492,9 @@ feat: email_history: description: Who was sent what, when? title: Based on their email history + joinform: + description: Find people who came in through a join form. + title: Based on join form source journey_subjects: description: Find people who are on a journey or finished it already title: People on a journey @@ -1543,6 +1614,10 @@ feat: notSent: not been sent opened: opened sent: been sent + joinForm: + anyForm: any join form + form: '"{title}"' + inputString: "{addRemoveSelect} people who came in through {formSelect} {timeFrame}" journey: condition: conditionSelect: @@ -1991,6 +2066,12 @@ feat: personRecordColumn: Respondent suggestedPeople: Suggested people unlink: Unlink + surveyDialog: + add: Add + cancel: Don't add + description: The person you are about to link does not have an email address + while the survey response does. Would you like to add it the person? + title: Add email address surveyForm: accept: I accept the terms stated below error: Something went wrong when submitting your answers. Please try again @@ -2555,7 +2636,13 @@ feat: delete: Delete list editQuery: Edit Smart Search query makeDynamic: Convert to Smart Search list - makeStatic: Convert to static list + makeStatic: + confirmDialogInfo: If you convert this list to a static list the Smart Search + will be removed and all the people the Smart Search returned will no + longer be there. + confirmDialogSubmitLabel: Convert + confirmDialogTitle: Convert to static list + label: Convert to static list jumpMenu: placeholder: Start typing to find list subtitle: diff --git a/src/locale/nn.yml b/src/locale/nn.yml index 7cfb93677..8123ef6b9 100644 --- a/src/locale/nn.yml +++ b/src/locale/nn.yml @@ -572,7 +572,6 @@ feat: locked: Mottakerne er låst for levering sentSubtitle: Du kan se smartsøket som ble brukt til å velge mottakerne av denne e-posten - subtitle: Bruk smarstøk til å velge hvem som skal motta e-posten. title: Mottakere viewButton: Se målgruppe unsubscribePage: diff --git a/src/locale/sv.yml b/src/locale/sv.yml index 6bc67f037..c279fd6d2 100644 --- a/src/locale/sv.yml +++ b/src/locale/sv.yml @@ -571,7 +571,6 @@ feat: locked: Målgrupp låst för leverans sentSubtitle: Du kan titta på den Smarta sökningen som användes för att definiera mottagarna av det här utskicket - subtitle: Gör en Smart sökning för att definiera mottagarna av det här utskicket. title: Målgrupp viewButton: Visa målgrupp unsubscribePage: diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx new file mode 100644 index 000000000..92cbba7c1 --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/instructions.tsx @@ -0,0 +1,141 @@ +import { GetServerSideProps } from 'next'; +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'; + +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 CanvassAssignmentInstructionsProps { + orgId: string; + canvassAssId: string; +} + +const CanvassAssignmentInstructionsPage: PageWithLayout< + CanvassAssignmentInstructionsProps +> = ({ orgId, canvassAssId }) => { + const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); + + // const messages = useMessages(messageIds); + + const { + hasNewText, + instructions, + isSaved, + isSaving, + isUnsaved, + revert, + save, + setInstructions, + } = useCanvassInstructions(parseInt(orgId), canvassAssId); + const [key, setKey] = useState(1); + + return ( + + + + Canvass Instructions +
{ + evt.preventDefault(); + save(); + }} + style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }} + > + + setInstructions(markdown)} + placeholder={'Add instructions for your canvassers'} + /> + + + + {isSaved && ( + {'Everything is up to date!'} + )} + {isUnsaved && ( + + {'You have unsaved changes. '} + { + showConfirmDialog({ + onSubmit: () => { + revert(); + //Force Slate to re-mount + setKey((current) => current + 1); + }, + warningText: + 'Do you want to delete all unsaved changes and go back to saved instructions?', + }); + }} + style={{ cursor: 'pointer', fontFamily: 'inherit' }} + > + {'Revert to saved version?'} + + + )} + + + +
+
+
+
+ ); +}; + +CanvassAssignmentInstructionsPage.getLayout = function getLayout(page) { + return ( + {page} + ); +}; + +export default CanvassAssignmentInstructionsPage;