From 17626e996196483e79c6b396bed78f1dd39c416a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 4 Jan 2024 19:51:24 +0100 Subject: [PATCH] feat: Communications form --- package.json | 5 +- src/pages/Home.jsx | 4 +- src/pages/SurveyUnitPage.jsx | 3 +- src/types/pearl.d.ts | 10 + src/ui/RadioLine.jsx | 1 + src/ui/SurveyUnit/CommunicationForm.jsx | 200 ++++++++++++++++++ src/ui/SurveyUnit/CommunicationsCard.jsx | 113 ++++++++++ src/ui/ValidationError.jsx | 22 ++ src/utils/functions/communicationFunctions.js | 2 +- src/utils/functions/seeder.js | 13 +- src/utils/hooks/useIncrement.js | 2 +- src/utils/hooks/useUser.js | 32 ++- .../services/surveyUnit-idb-service.js | 5 + src/utils/schemas.js | 37 ++++ yarn.lock | 5 + 15 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/ui/SurveyUnit/CommunicationForm.jsx create mode 100644 src/ui/SurveyUnit/CommunicationsCard.jsx create mode 100644 src/ui/ValidationError.jsx create mode 100644 src/utils/schemas.js diff --git a/package.json b/package.json index c8ee9ab3b..1918432e1 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "workbox-core": "^5.1.3", "workbox-precaching": "^5.1.3", "workbox-routing": "^5.1.3", - "workbox-strategies": "^5.1.3" + "workbox-strategies": "^5.1.3", + "zod": "^3.22.4" }, "scripts": { "dev": "vite", @@ -129,6 +130,6 @@ "vitest": "^0.29.7" }, "volta": { - "node": "16.20.2" + "node": "20.10.0" } } diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index cd4df885c..32ee711d7 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -23,7 +23,7 @@ import { SearchField } from '../ui/SearchField'; import { SurveyCard } from '../ui/SurveyCard'; import { Row } from '../ui/Row'; import { Select } from '../ui/Select'; -import { seedSurveyUnits } from '../utils/functions/seeder'; +import { seedData } from '../utils/functions/seeder'; export function Home() { const surveyUnits = useSurveyUnits(); @@ -65,7 +65,7 @@ export function Home() { Vous êtes en mode développement - diff --git a/src/pages/SurveyUnitPage.jsx b/src/pages/SurveyUnitPage.jsx index 08a066140..6c91cc0cc 100644 --- a/src/pages/SurveyUnitPage.jsx +++ b/src/pages/SurveyUnitPage.jsx @@ -9,6 +9,7 @@ import { AddressCard } from '../ui/SurveyUnit/AddressCard'; import { IdentificationCard } from '../ui/SurveyUnit/IdentificationCard'; import { PersonsCard } from '../ui/SurveyUnit/PersonsCard'; import { ContactsCard } from '../ui/SurveyUnit/ContactsCard'; +import { CommunicationsCard } from '../ui/SurveyUnit/CommunicationsCard'; export function SurveyUnitPage() { const { id } = useParams(); @@ -38,7 +39,7 @@ export function SurveyUnitPage() { - c + d diff --git a/src/types/pearl.d.ts b/src/types/pearl.d.ts index beb6664a6..ae84daf93 100644 --- a/src/types/pearl.d.ts +++ b/src/types/pearl.d.ts @@ -74,6 +74,14 @@ declare global { medium: string; }; + type SurveyUnitCommunicationRequest = { + emiter: 'INTERVIEWER' | 'TOOL'; + medium: string; + reason: string; + type: string; + status: { date: number; status: string }[]; + }; + type SurveyUnit = { id: string; persons: SurveyUnitPerson[]; @@ -97,6 +105,8 @@ declare global { identificationConfiguration: string; contactOutcomeConfiguration: string; contactAttemptConfiguration: string; + communicationRequestConfiguration: boolean; + communicationRequests: SurveyUnitCommunicationRequest[]; }; type Notification = { diff --git a/src/ui/RadioLine.jsx b/src/ui/RadioLine.jsx index 6819787b2..9b93bfc0b 100644 --- a/src/ui/RadioLine.jsx +++ b/src/ui/RadioLine.jsx @@ -32,6 +32,7 @@ const style = { export function RadioLine({ value, disabled, label, checked, ...props }) { return ( } diff --git a/src/ui/SurveyUnit/CommunicationForm.jsx b/src/ui/SurveyUnit/CommunicationForm.jsx new file mode 100644 index 000000000..6c3202a53 --- /dev/null +++ b/src/ui/SurveyUnit/CommunicationForm.jsx @@ -0,0 +1,200 @@ +import DialogTitle from '@mui/material/DialogTitle'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import Stack from '@mui/material/Stack'; +import D from 'i18n'; +import { useIncrement } from '../../utils/hooks/useIncrement'; +import RadioGroup from '@mui/material/RadioGroup'; +import { RadioLine } from '../RadioLine'; +import Box from '@mui/material/Box'; +import { Typography } from '../Typography'; +import { getAddressData, getprivilegedPerson, getTitle } from '../../utils/functions'; +import { surveyUnitIDBService } from '../../utils/indexeddb/services/surveyUnit-idb-service'; +import { COMMUNICATION_REQUEST_FORM_STEPS } from '../../utils/constants'; +import { useUser } from '../../utils/hooks/useUser'; +import { communicationSchema, recipientSchema, userSchema } from '../../utils/schemas'; +import { ValidationError } from '../ValidationError'; +import { communicationStatusEnum } from '../../utils/enum/CommunicationEnums'; + +const max = COMMUNICATION_REQUEST_FORM_STEPS.length - 1; + +/** + * Form to add a new contact attempt to a survey unit + * + * @param {() => void} onClose + * @param {SurveyUnit} surveyUnit + * @returns {JSX.Element} + */ +export function CommunicationForm({ onClose, surveyUnit }) { + const { value: currentIndex, decrement, increment } = useIncrement(0, { + min: 0, + max: max, + }); + const [isConfirmValid, setConfirmValid] = useState(false); + const step = COMMUNICATION_REQUEST_FORM_STEPS[currentIndex]; + /** @var {SurveyUnitCommunicationRequest} communication */ + const [communication, setCommunication] = useState({ + medium: '', + reason: '', + type: '', + }); + + const goPreviousStep = e => { + e.preventDefault(); + if (step === 0) { + onClose(); + return; + } + decrement(); + }; + + const goNextStep = async e => { + e.preventDefault(); + if (currentIndex === max) { + const newCommunication = { + ...communication, + date: new Date().getTime(), + emiter: 'INTERVIEWER', + status: [{ date: new Date().getTime(), status: communicationStatusEnum.INITIATED.type }], + }; + surveyUnitIDBService.addOrUpdateSU({ + ...surveyUnit, + communicationRequests: [...(surveyUnit.communicationRequests ?? []), newCommunication], + }); + onClose(); + return; + } + increment(); + }; + + const setValue = value => { + setCommunication({ + ...communication, + [step.valueName]: value, + }); + }; + + const options = useMemo(() => step?.values, [step]); + const isValid = currentIndex === max ? isConfirmValid : !!communication[step.valueName]; + const isFirst = currentIndex === 0; + const isLast = currentIndex === max; + + return ( + + {step.title} + + + {options && ( + setValue(e.target.value)} + row + aria-labelledby="dialogtitle" + name={name} + > + + {options.map(o => ( + + ))} + + + )} + {isLast && ( + + )} + + + + + + + + ); +} + +/** + * @param {SurveyUnitCommunicationRequest} communication + * @param {SurveyUnit} surveyUnit + * @param {(v: boolean) => void} onValidationChange + */ +function CommunicationConfirmation({ communication, surveyUnit, onValidationChange }) { + // Extract selected labels + const items = COMMUNICATION_REQUEST_FORM_STEPS.filter(v => v.values).map( + v => v.values.find(value => value.value === communication[v.valueName]).label + ); + const address = getAddressData(surveyUnit.address); + const addressLines = Object.values(address).filter(v => !!v); + const recipient = getprivilegedPerson(surveyUnit); + const { user } = useUser(); + + const userError = userSchema.safeParse(user).error; + const recipientError = recipientSchema.safeParse({ ...address, ...recipient }).error; + const communicationError = communicationSchema.safeParse(communication).error; + + useEffect(() => { + onValidationChange(!(userError || recipientError || communicationError)); + }, []); + + return ( + +
+ + {D.communicationSummaryContent} + + + {items.map(item => ( + + {item} + + ))} + + +
+
+ + {D.communicationSummaryRecipientAddress} + + + {getTitle(recipient.title)} {recipient.firstName} {recipient.lastName} +
+ {addressLines.map(line => ( + + {line} +
+
+ ))} +
+ +
+
+ + {D.communicationSummaryInterviewerAddress} + + + {user.firstName} {user.lastName} +
+ {user.email} +
+ {user.phoneNumber} +
+ +
+
+ ); +} diff --git a/src/ui/SurveyUnit/CommunicationsCard.jsx b/src/ui/SurveyUnit/CommunicationsCard.jsx new file mode 100644 index 000000000..1ae1f539b --- /dev/null +++ b/src/ui/SurveyUnit/CommunicationsCard.jsx @@ -0,0 +1,113 @@ +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import { Typography } from '../Typography'; +import D from 'i18n'; +import { Row } from '../Row'; +import Stack from '@mui/material/Stack'; +import React from 'react'; +import Button from '@mui/material/Button'; +import { useToggle } from '../../utils/hooks/useToggle'; +import CampaignIcon from '@mui/icons-material/Campaign'; +import AddIcon from '@mui/icons-material/Add'; +import BuildIcon from '@mui/icons-material/Build'; +import DirectionsWalkIcon from '@mui/icons-material/DirectionsWalk'; +import Divider from '@mui/material/Divider'; +import { formatDate } from '../../utils/functions/date'; +import { + findCommunicationMediumValueByType, + findCommunicationReasonValueByType, + findCommunicationStatusValueByType, + findCommunicationTypeValueByType, +} from '../../utils/enum/CommunicationEnums'; +import { HEALTHY_COMMUNICATION_REQUEST_STATUS } from '../../utils/constants'; +import ClearIcon from '@mui/icons-material/Clear'; +import CheckIcon from '@mui/icons-material/Check'; +import { CommunicationForm } from './CommunicationForm'; +import { canSendCommunication } from '../../utils/functions'; + +/** + * @param {SurveyUnit} surveyUnit + */ +export function CommunicationsCard({ surveyUnit }) { + const [showModal, toggleModal] = useToggle(false); + const canAddCommunication = canSendCommunication(surveyUnit); + + return ( + <> + + + + + + + {D.surveyUnitCommunications} + + + + + {surveyUnit.communicationRequests?.map(v => ( + + ))} + + + + + {showModal && } + + ); +} + +/** + * @param {SurveyUnitCommunicationRequest} communication + * @constructor + */ +function CommunicationItem({ communication }) { + const IconComponent = + communication.emiter.toUpperCase() === 'INTERVIEWER' ? DirectionsWalkIcon : BuildIcon; + const sortedStatus = [...communication.status].sort((s1, s2) => s1.date > s2.date); + const firstStatus = sortedStatus.at(0); + const lastStatus = sortedStatus.at(-1); + const statusIcon = HEALTHY_COMMUNICATION_REQUEST_STATUS.includes(lastStatus.status) ? ( + + ) : ( + + ); + + return ( + + + + +
+ + {formatDate(firstStatus.date, true)} + + +  | {findCommunicationMediumValueByType(communication.medium)} -{' '} + {findCommunicationTypeValueByType(communication.type)} + {communication.reason && + `, ${findCommunicationReasonValueByType(communication.reason)}`} + +
+
+ + {findCommunicationStatusValueByType(lastStatus.status)} {D.communicationStatusOn}{' '} + {formatDate(lastStatus.date)} + {statusIcon} + +
+ ); +} diff --git a/src/ui/ValidationError.jsx b/src/ui/ValidationError.jsx new file mode 100644 index 000000000..c19065876 --- /dev/null +++ b/src/ui/ValidationError.jsx @@ -0,0 +1,22 @@ +import Box from '@mui/material/Box'; +import { ZodError } from 'zod'; + +/** + * Display information about errors + * @param {ZodError} error + * @param {import('@mui/material').BoxProps} props + */ +export function ValidationError({ error, ...props }) { + if (!error || !(error instanceof ZodError)) { + return null; + } + return ( + + {error.issues.map((issue, k) => ( + + {issue.path.join('.')} : {issue.message} + + ))} + + ); +} diff --git a/src/utils/functions/communicationFunctions.js b/src/utils/functions/communicationFunctions.js index ff8f875a9..7b5e8cbdc 100644 --- a/src/utils/functions/communicationFunctions.js +++ b/src/utils/functions/communicationFunctions.js @@ -21,7 +21,7 @@ export const VALID_MAIL_FORMAT = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/ export const isEmailValid = email => email?.match(VALID_MAIL_FORMAT) ?? false; -export const isValidTitle = title => Object.keys(TITLES).includes(title.toUpperCase()); +export const isValidTitle = title => Object.keys(TITLES).includes((title ?? '').toUpperCase()); export const isValidString = string => !!string && string?.length !== 0; export const checkCommunicationRequestFormAddressesValidity = ( diff --git a/src/utils/functions/seeder.js b/src/utils/functions/seeder.js index f4731752e..7930e3728 100644 --- a/src/utils/functions/seeder.js +++ b/src/utils/functions/seeder.js @@ -1,6 +1,7 @@ import { surveyUnitStateEnum } from '../enum/SUStateEnum'; import { contactOutcomeEnum } from '../enum/ContactOutcomeEnum'; import { surveyUnitIDBService } from '../indexeddb/services/surveyUnit-idb-service'; +import userIdbService from '../indexeddb/services/user-idb-service'; const day = 60 * 60 * 1000 * 24; const year = day * 365; @@ -27,7 +28,7 @@ const year = day * 365; * }} company - The user's company information. */ -export async function seedSurveyUnits() { +export async function seedData() { /** @var {SurveyUnit[]} surveyUnits */ const surverUnits = []; /** @var {User[]} users */ @@ -174,7 +175,15 @@ export async function seedSurveyUnits() { }, }); } - surveyUnitIDBService.addAll(surverUnits); + await userIdbService.insert({ + id: 1, + title: 'MISTER', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '0123456789', + email: 'john@doe.fr', + }); + await surveyUnitIDBService.addAll(surverUnits); } /** diff --git a/src/utils/hooks/useIncrement.js b/src/utils/hooks/useIncrement.js index f33dde179..89f7b74f6 100644 --- a/src/utils/hooks/useIncrement.js +++ b/src/utils/hooks/useIncrement.js @@ -13,6 +13,6 @@ export function useIncrement(initial = 0, { min, max }) { return { value, increment: useCallback(() => setValue(v => Math.min(max ?? Infinity, v + 1)), [max]), - decrement: useCallback(() => setValue(v => Math.min(min ?? -Infinity, v - 1)), [min]), + decrement: useCallback(() => setValue(v => Math.max(min ?? -Infinity, v - 1)), [min]), }; } diff --git a/src/utils/hooks/useUser.js b/src/utils/hooks/useUser.js index 049b8927f..35229cd73 100644 --- a/src/utils/hooks/useUser.js +++ b/src/utils/hooks/useUser.js @@ -1,10 +1,30 @@ +import { signal } from '@maverick-js/signals'; +import { useSignalValue } from './useSignalValue'; +import { db } from '../indexeddb/idb-config'; +import { liveQuery } from 'dexie'; + +const user = signal({ + firstName: 'Unknown', + lastName: 'Interviewer', + phoneNumber: '0123456789', + email: 'no.data@y.et', +}); + +// Watch change to update the signal when user info changes +liveQuery(() => db.user.limit(1).toArray()).subscribe({ + next: result => { + if (result.length > 0) { + user.set(result[0]); + } + }, + error: error => console.error(error), +}); + +/** + * @returns {{user: {firstName: string, lastName: string, phoneNumber: string, email: string}}} + */ export function useUser() { return { - user: { - firstName: 'Unknown', - lastName: 'Interviewer', - phoneNumber: '0123456789', - email: 'no.data@y.et', - }, + user: useSignalValue(user), }; } diff --git a/src/utils/indexeddb/services/surveyUnit-idb-service.js b/src/utils/indexeddb/services/surveyUnit-idb-service.js index 244d718af..5b578cd26 100644 --- a/src/utils/indexeddb/services/surveyUnit-idb-service.js +++ b/src/utils/indexeddb/services/surveyUnit-idb-service.js @@ -5,6 +5,11 @@ class SurveyUnitIdbService extends AbstractIdbService { super('surveyUnit'); } + /** + * Update or insert a surveyUnit if the ID is unknown + * @param {SurveyUnit} item + * @returns {Promise} + */ async addOrUpdateSU(item) { const { id, ...other } = item; const surveyUnit = await this.getById(id); diff --git a/src/utils/schemas.js b/src/utils/schemas.js new file mode 100644 index 000000000..bb090e8e3 --- /dev/null +++ b/src/utils/schemas.js @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { mediumRadioValues, reasonRadioValues, TITLES, typeRadioValues } from './constants'; + +const stringRequired = z + .string({ required_error: 'Ce champs est requis' }) + .min(1, { message: 'Vous devez entrer une valeur' }); + +export const communicationSchema = z.object({ + medium: z.enum( + mediumRadioValues.map(v => v.value), + { required_error: 'Requis' } + ), + type: z.enum( + typeRadioValues.map(v => v.value), + { required_error: 'Requis' } + ), + reason: z.enum( + reasonRadioValues.map(v => v.value), + { required_error: 'Requis' } + ), +}); + +export const userSchema = z.object({ + title: z.enum(Object.keys(TITLES)), + firstName: stringRequired, + lastName: stringRequired, + email: stringRequired.email({ message: 'Cet email ne semble pas être valide' }), + phoneNumber: z.string().min(10, { message: 'Ce numéro de téléphone est trop court' }), +}); + +export const recipientSchema = z.object({ + title: z.enum(Object.keys(TITLES), { required_error: 'Requis' }), + firstName: stringRequired, + lastName: stringRequired, + postCode: stringRequired, + cityName: stringRequired, +}); diff --git a/yarn.lock b/yarn.lock index 8b519fe8c..2bca5f3a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6111,3 +6111,8 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==